Merge "Upgrade ICU4J to 77.1"
diff --git a/Documentation/cmd-create-account.txt b/Documentation/cmd-create-account.txt
index 502b7a7..f26239e0 100644
--- a/Documentation/cmd-create-account.txt
+++ b/Documentation/cmd-create-account.txt
@@ -12,6 +12,7 @@
[--email <EMAIL>]
[--ssh-key - | <KEY>]
[--http-password <PASSWORD>]
+ [--token <TOKEN>]
<USERNAME>
--
@@ -62,7 +63,14 @@
Preferred email address for the user account.
--http-password::
- HTTP password for the user account.
+ HTTP password for the user account. (deprecated)
+
+--token::
+ Authentication token for the user account.
+
+--token-id::
+ ID used for the provided token. If not provided, the token id will
+ be generated based on the timestamp.
== EXAMPLES
Create a new batch/role access user account called `watcher` in
diff --git a/Documentation/cmd-set-account.txt b/Documentation/cmd-set-account.txt
index 02eaf83..3b62651 100644
--- a/Documentation/cmd-set-account.txt
+++ b/Documentation/cmd-set-account.txt
@@ -15,6 +15,9 @@
[--generate-http-password]
[--http-password <PASSWORD>]
[--clear-http-password]
+ [--token <TOKEN>]
+ [--generate-token <TOKEN-ID>]
+ [--delete-token <TOKEN-ID>]
[--delete-external-id <EXTERNALID>] <USER>
--
@@ -107,6 +110,18 @@
--clear-http-password::
Clear the HTTP password for the user account.
+--token::
+ Authorization token for the user account. Multiple --token
+ options may be specified to add multiple tokens to an account.
+ Requires administrator privileges.
+
+--generate-token::
+ Generate a new random token for the user account. The token
+ will be output to the user on success.
+
+--delete-token::
+ Delete a token from a user's account.
+
--delete-external-id::
Delete an external ID from a user's account if it exists.
If the external ID provided is 'ALL', all associated
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index b1c91f9..7f74348 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -141,6 +141,11 @@
+
Stores the link:#project-watches[project watches] of the account.
+
+* `tokens.config`:
++
+Stores the link:#authentication-tokens[authentication tokens] of the account.
+
In addition it contains an
link:https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys[
authorized_keys,role=external,window=_blank] file with the link:#ssh-keys[SSH keys] of the account.
@@ -251,6 +256,39 @@
available from the account cache and Gerrit can check if any watch
matches the change and the event.
+[[authentication-tokens]]
+=== Authentication Tokens
+
+When `auth.gitBasicAuthPolicy` is set to `HTTP` or `HTTP_LDAP`, Gerrit requires
+users to authenticate with an authentication token generated in Gerrit when
+using the REST API or Git to communicate with the Gerrit server.
+
+These authentication tokens are stored in the user branch in the `tokens.config`
+file. The file uses the Git config format:
+
+----
+[token "some-token-id"]
+ hash = bcrypt0:4:....
+ expiration = 2025-07-30T07:14:10.745274Z
+----
+
+Each subsection represents a token. The subsection name represents an ID that
+is unique for the user.
+
+The token is never stored in plain text. Only the serialized hash of the token
+will be stored which can be used to verify the token during authentication.
+
+A token may have an expiration timestamp. This timestamp is always in UTC and
+uses the DateTimeFormatter#ISO_INSTANT format. A token is considered valid if
+the expiration timestamp is in the future. If no expiration timestamp is
+provided the token is considered to be valid forever or until it is deleted.
+
+When a token is deleted, the respective subsection is removed from the file.
+
+During authentication Gerrit will verify the provided token against all valid
+tokens present in the account. If any token matches the provided token,
+authentication will succeed.
+
[[ssh-keys]]
=== SSH Keys
@@ -347,7 +385,9 @@
subsection name.
The `accountId` field is mandatory. The `email` and `password` fields
-are optional.
+are optional. The `password` field is deprecated, because passwords
+are being replaced by tokens, which are stored in the account's user
+reference.
Note that git will automatically nest these notes at varying levels. If
refs/meta/external-ids:7c/2a55657d911109dbc930836e7a770fb946e8ef is not
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index ba81b0f..b43f6cc 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -278,12 +278,15 @@
<<ldap.authentication,ldap.authentication>> is set to `GSSAPI`.
+
If link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
-the randomly generated HTTP password is used for authentication. On the other hand,
-if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
-the password in the request is first checked against the HTTP password and, if
-it does not match, it is then validated against the LDAP password.
+the randomly generated authentication tokens are used for authentication. On the
+other hand, if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set
+to `HTTP_LDAP`, the password in the request is first checked against the token
+and, if it does not match, it is then validated against the LDAP password.
Service users that are link:cmd-create-account.html[internal-only] are
-authenticated by their HTTP passwords.
+authenticated by their tokens.
++
+Note, that in an interim period, if the user does not have an authentication
+token, Gerrit will fall back to a potentially still existing HTTP password.
* `LDAP_BIND`
+
@@ -621,13 +624,14 @@
[[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
+
When `auth.type` is `LDAP`, `LDAP_BIND` or `OAUTH`, it allows using either the generated
-HTTP password, the LDAP or OAUTH password, or a combination of HTTP and LDAP
-authentication, to authenticate Git over HTTP and REST API requests.
+tokens, the LDAP or OAUTH password, or a combination of HTTP and LDAP authentication,
+to authenticate Git over HTTP and REST API requests.
The supported values are:
+
*`HTTP`
+
-Only the HTTP password is accepted when doing Git over HTTP and REST API requests.
+Only the authentication tokens, with a fallback to HTTP passwords, are accepted
+when doing Git over HTTP and REST API requests.
+
*`LDAP`
+
@@ -641,7 +645,7 @@
+
*`HTTP_LDAP`
+
-The password in the request is first checked against the HTTP password and, if
+The password in the request is first checked against the authentication tokens and, if
it does not match, it is then validated against the `LDAP` password.
+
By default this is set to `LDAP` when link:#auth.type[`auth.type`] is `LDAP`
@@ -655,6 +659,31 @@
which could introduce a noticeable latency on the overall execution
and produce unwanted load to the LDAP server.
+[[auth.maxAuthTokenLifetime]]auth.maxAuthTokenLifetime::
++
+The maximum lifetime allowed for an authentication token. The minimum time is
+1 minute.
+Values should use common unit suffixes to express their setting:
++
+* m, min, minute, minutes
+* h, hr, hour, hours
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
++
+If not set or if a value <= 0 is used, tokens can have an unlimited lifetime.
++
+Existing tokens will not be affected by changing this value.
+
+[[auth.maxAuthTokensPerAccount]]auth.maxAuthTokensPerAccount::
++
+The maximum number of authentication tokens a user is allowed to have in their
+account. If that number is reached, creating a new token will fail until an
+existing token is being deleted. Expired tokens count to the limit.
++
+Defaults to 10.
+
[[auth.gitOAuthProvider]]auth.gitOAuthProvider::
+
Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 1ec5208..49ec3f4 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -95,6 +95,11 @@
The Footer templates will determine the contents of the footer text appended to
the end of all outgoing emails after the ChangeFooter and CommentFooter.
+=== AuthTokenUpdate.soy and AuthTokenUpdateHtml.soy
+
+AuthTokenUpdate templates will determine the contents of the email related to adding,
+changing or deleting an authentication token on a user account.
+
=== HttpPasswordUpdate.soy and HttpPasswordUpdateHtml.soy
HttpPasswordUpdate templates will determine the contents of the email related to adding,
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index 7cf61e4..4336d3c 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -62,20 +62,9 @@
which is always sent with a no-cache header. Clients will see any
changes immediately after they are made.
-Assets under `'$site_path'/static` whose file name matches one of the
-following patterns are served with a 1 year expiration, permitting
-very aggressive caching by clients and edge-proxies:
-
- * `*.cache.html`
- * `*.cache.gif`
- * `*.cache.png`
- * `*.cache.css`
- * `*.cache.jar`
- * `*.cache.swf`
-
-All other assets under `'$site_path'/static` are served with a 5
-minute expire, permitting some (limited) caching. It may take up
-to 5 minutes after making a change, before clients see the changes.
+Assets under `'$site_path'/static` are served with a 15 minutes
+expiration, permitting some (limited) caching. It may take up
+to 15 minutes after making a change, before clients see the changes.
It is recommended that static images used in the site header
or footer be named with a unique caching file name, for example
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 70f41af..a22caec 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -148,8 +148,8 @@
git clone http://username@localhost:8080/projectname
----
-The default password for user `admin` is `secret`. You can regenerate a
-password in the UI under User Settings -- HTTP credentials. The password can be
+The default token for user `admin` is `secret`. You can generate tokens
+in the UI under User Settings -- HTTP credentials. The tokens can be
stored locally to avoid retyping it:
----
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index a28e230..0b21b7f 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -73,7 +73,7 @@
curl -n http://localhost:8080/a/path/to/api/
----
-In both cases, the password should be the user's link:user-upload.html#http[HTTP password].
+In both cases, the password should be one of the user's link:user-upload.html#http[authentication tokens].
=== Verifying Header Content
diff --git a/Documentation/pgm-MigratePasswordsToTokens.txt b/Documentation/pgm-MigratePasswordsToTokens.txt
new file mode 100644
index 0000000..1a7b7d5
--- /dev/null
+++ b/Documentation/pgm-MigratePasswordsToTokens.txt
@@ -0,0 +1,49 @@
+= MigratePasswordsToTokens
+
+== NAME
+MigratePasswordsToTokens - Convert HTTP passwords of all users
+to authentication tokens.
+
+== SYNOPSIS
+[verse]
+--
+_java_ -jar gerrit.war _MigratePasswordsToTokens_
+ -d <SITE_PATH>
+ --lifetime <LIFETIME>
+--
+
+== DESCRIPTION
+The HTTP passwords have been deprecated in favor of authentication tokens.
+This command migrates all users' HTTP passwords to authentication tokens.
+It will delete the password from the `username` ExternalID and create a new
+token in the user-ref of the account. The new token will have the ID
+`legacy` and have the same value as the password.
+
+== OPTIONS
+
+-d::
+--site-path::
+ Path of the Gerrit site
+
+--lifetime::
+ Default lifetime of the tokens
+
+== CONTEXT
+This command can only be run offline with direct access to the server's
+site.
+
+== EXAMPLES
+To convert the HTTP passwords to tokens:
+
+----
+ $ java -jar gerrit.war MigratePasswordsToTokens -d site_path
+----
+
+== SEE ALSO
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/pgm-ReduceMaxTokenLifetime.txt b/Documentation/pgm-ReduceMaxTokenLifetime.txt
new file mode 100644
index 0000000..80adafb6
--- /dev/null
+++ b/Documentation/pgm-ReduceMaxTokenLifetime.txt
@@ -0,0 +1,47 @@
+= ReduceMaxTokenLifetime
+
+== NAME
+ReduceMaxTokenLifetime - Adapt lifetime of existing auth tokens to lower
+maximum lifetime.
+
+== SYNOPSIS
+[verse]
+--
+_java_ -jar gerrit.war _ReduceMaxTokenLifetime_
+ -d <SITE_PATH>
+ --lifetime <LIFETIME>
+--
+
+== DESCRIPTION
+If the maximum lifetime of auth tokens is being reduced, existing tokens might
+still have a longer lifetime. If the lifetime of these tokens should be reduced
+to match the new maximum lifetime, this can be done with this tool.
+
+== OPTIONS
+
+-d::
+--site-path::
+ Path of the Gerrit site
+
+--lifetime::
+ New maximum lifetime
+
+== CONTEXT
+This command can only be run offline with direct access to the server's
+site.
+
+== EXAMPLES
+To convert the HTTP passwords to tokens:
+
+----
+ $ java -jar gerrit.war ReduceMaxTokenLifetime -d site_path --lifetime "2d"
+----
+
+== SEE ALSO
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 66c6531..172c541 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -167,7 +167,10 @@
"display_name": "Super John",
"email": "john.doe@example.com",
"ssh_key": "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA0T...YImydZAw==",
- "http_password": "19D9aIn7zePb",
+ "tokens": [{
+ "id": "token1",
+ "token": "19D9aIn7zePb"
+ }],
"groups": [
"MyProject-Owners"
]
@@ -527,11 +530,17 @@
[[set-http-password]]
=== Set/Generate HTTP Password
+
+*Note*: The use of HTTP passwords is deprecated. It is recommended to use
+authentication tokens instead. See link:#create-token[Create Authentication
+token]. This endpoint serves as an alias for link:#create-token[Create
+Authentication token] to provide backwards compatibility.
+
--
'PUT /accounts/link:#account-id[\{account-id\}]/password.http'
--
-Sets/Generates the HTTP password of an account.
+Sets/Generates an authentication token with id `legacy` for an account.
The options for setting/generating the HTTP password must be provided
in the request body inside a link:#http-password-input[
@@ -549,7 +558,7 @@
}
----
-As response the new HTTP password is returned.
+As response the new token is returned.
.Response
----
@@ -561,15 +570,21 @@
"ETxgpih8xrNs"
----
-If the HTTP password was deleted the response is "`204 No Content`".
+If the token was deleted the response is "`204 No Content`".
[[delete-http-password]]
=== Delete HTTP Password
+
+*Note*: The use of HTTP passwords is deprecated. It is recommended to use
+authentication tokens instead. See link:#delete-token[Delete Authentication
+token]. This endpoint serves as an alias for link:#delete-token[Delete
+Authentication token] to provide backwards compatibility.
+
--
'DELETE /accounts/link:#account-id[\{account-id\}]/password.http'
--
-Deletes the HTTP password of an account.
+Deletes the token with id `legacy` of an account.
.Request
----
@@ -581,6 +596,116 @@
HTTP/1.1 204 No Content
----
+[[create-token]]
+=== Create Authentication token
+--
+'PUT /accounts/link:#account-id[\{account-id\}]/tokens/\{token-id\}'
+--
+
+Creates a new token for an account. The token is usually generated.
+Administrators can also set a specific token for an account.
+
+The options for setting the token must be provided in the
+request body inside a link:#auth-token-input[AuthTokenInput] entity.
+
+The account must have a username.
+The token-id must be unique among other token-ids used by the account. It has to
+start with a letter and must only contain upper- or lowercase letters, digits,
+`-` or `_`.
+
+Optionally, a lifetime for the token can be configured. The minimum lifetime is
+1 minute. The lifetime has to be provided using the following unit format:
+
+* m, min, minute, minutes
+* h, hr, hour, hours
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
+
+If no lifetime is provided, the token's lifetime will be unlimited.
+
+.Request
+----
+ PUT /accounts/self/tokens/example HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "id": "example",
+ "token": "secret_token_123",
+ "lifetime": "30d"
+ }
+----
+
+As response a link:#auth-token-info[AuthTokenInfo] containing the new token
+is returned. Note, that this is the only time the plain text token
+will be returned by Gerrit.
+
+.Response
+----
+ HTTP/1.1 201 Created
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "id": "example",
+ "token": "someRandomToken",
+ "expiration": "2025-05-02 08:54:35.198000000"
+ }
+----
+
+[[get-tokens]]
+=== List Authentication Tokens
+--
+'GET /accounts/link:#account-id[\{account-id\}]/tokens'
+--
+
+Lists the token ids of an account as a list of link:#auth-token-info[AuthTokenInfos].
+The plain text token will never be returned.
+
+.Request
+----
+ GET /accounts/self/tokens HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ [
+ {
+ "id": "example",
+ "expiration": "2025-05-02 08:54:35.198000000"
+ },
+ {
+ "id": "another",
+ "expiration": "2025-05-04 08:54:35.198000000"
+ }
+ ]
+----
+
+[[delete-token]]
+=== Delete Authentication Token
+--
+'DELETE /accounts/link:#account-id[\{account-id\}]/tokens/\{token-id\}'
+--
+
+Deletes the token with the given `token-id` of an account.
+
+.Request
+----
+ DELETE /accounts/self/tokens/example HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 204 No Content
+----
+
[[get-oauth-token]]
=== Get OAuth Access Token
--
@@ -2331,7 +2456,9 @@
|`display_name` |optional|The display name of the user.
|`email` |optional|The email address of the user.
|`ssh_key` |optional|The public SSH key of the user.
-|`http_password`|optional|The HTTP password of the user.
+|`http_password`|optional|The HTTP password of the user. (deprecated)
+|`tokens` |optional|
+A list of tokens in the form of link:#auth-token-input[AuthTokenInputs] to assign to the user.
|`groups` |optional|
A list of link:rest-api-groups.html#group-id[group IDs] that identify
the groups to which the user should be added.
@@ -2777,6 +2904,37 @@
password is deleted.
|============================
+[[auth-token-input]]
+=== AuthTokenInput
+The `AuthTokenInput` entity contains information for setting/generating
+an authentication token.
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name ||Description
+|`id` |If not set, the id in the URL will be used.|
+Must be the same as the id used in the URL.
+|`token` |optional|
+The new token. Only Gerrit administrators may set the token directly.
+|`lifetime` |optional|
+Lifetime of the token. After the given duration the token will be invalid.
+|============================
+
+[[auth-token-info]]
+=== AuthTokenInfo
+The `AuthTokenInfo` entity contains information about an authentication token.
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name ||Description
+|`id` ||The id of the token.
+|`token` |optional|
+The token in plain text. Will only be returned once when creating the token.
+|`expiration` |optional|
+The timestamp at which the token will expire or has been expired. If `null`, token
+lifetime is unlimited.
+|============================
+
[[oauth-token-info]]
=== OAuthTokenInfo
The `OAuthTokenInfo` entity contains information about an OAuth access token.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 16032ff..ba48e8e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7928,6 +7928,20 @@
[options="header",cols="1,^1,5"]
|=============================
|Field Name ||Description
+|`base` |optional|
+The SHA1 of the commit that was used as the base commit for the Git merge that created the
+revision. +
+A base is not set if: +
+- the merged commits do not have a common ancestor (in this case `no_base_reason` is
+`NO_COMMON_ANCESTOR`). +
+- the merged commits have multiple merge bases (happens for criss-cross-merges) and the base was
+computed (in this case `no_base_reason` is `COMPUTED_BASE`). +
+- a one sided merge strategy (e.g. `ours` or `theirs`) has been used and computing a base was not
+required for the merge (in this case `no_base_reason` is `ONE_SIDED_MERGE_STRATEGY`). +
+- the revision was not created by performing a Git merge operation (in this case `no_base_reason`
+is `NO_MERGE_PERFORMED`). +
+- the revision has been created before Gerrit started to store the base for conflicts (in this case
+`no_base_reason` is `HISTORIC_DATA_WITHOUT_BASE`).
|`ours` |optional|
The SHA1 of the commit that was used as "ours" for the Git merge that created the revision. +
- For merge commits that are created by the link:#create-change[Create Change] REST endpoint
@@ -7963,6 +7977,21 @@
patch set is being rebased. +
Guaranteed to be set if `contains_conflicts` is `true`. If `contains_conflicts` is `false`, only
set if the revision was created by Gerrit as a result of performing a Git merge.
+|`merge_strategy` |optional|
+The merge strategy was used for the Git merge that created the revision. +
+Possible values: `resolve`, `recursive`, `simple-two-way-in-core`, `ours` and `theirs`.
+|`no_base_reason` |optional|
+Reason why `base` is not set. +
+Only set if `base` is not set. +
+Possible values are: +
+- `NO_COMMON_ANCESTOR`: The merged commits do not have a common ancestor. +
+- `COMPUTED_BASE`: The merged commits have multiple merge bases (happens for criss-cross-merges)
+and the base was computed. +
+- `ONE_SIDED_MERGE_STRATEGY`: A one sided merge strategy (e.g. `ours` or `theirs`) has been used
+and computing a base was not required for the merge. +
+- `NO_MERGE_PERFORMED`: The revision was not created by performing a Git merge operation. +
+- `HISTORIC_DATA_WITHOUT_BASE`: The revision has been created before Gerrit started to store the
+base for conflicts.
|`contains_conflicts` ||
Whether any of the files in the revision has a conflict due to merging "ours" and "theirs". +
If "true" at least one of the files in the revision has a conflict and contains Git conflict
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index c88e48c..d24ba4c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1827,6 +1827,34 @@
Content-Disposition: attachment
----
+[[passwords.to.tokens]]
+=== Migration of HTTP passwords to authentication tokens
+
+This endpoint allows Gerrit administrators to migrate remaining HTTP passwords
+to authentication tokens. For the user the migration is invisible, i.e. the
+same credential is still valid.
+
+An optional lifetime for the credential can be set in the
+link:#migrate-passwords-to-tokens-input[MigratePasswordsToTokens.Input].
+
+This migration task will run asynchronously.
+
+.Request
+----
+ POST /config/server/passwords.to.tokens HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "lifetime": "3 months",
+ }
+----
+
+.Response
+----
+ HTTP/1.1 202 Accepted
+ Content-Disposition: attachment
+----
+
[[experiment-endpoints]]
== Experiment Endpoints
@@ -1964,6 +1992,9 @@
Git over HTTP and REST API requests when
link:config-gerrit.html#auth.type[authentication type] is `LDAP`,
`LDAP_BIND` or `OAUTH`. Can be `HTTP`, `LDAP`, `HTTP_LDAP` or `OAUTH`.
+|`max_token_lifetime` |optional|
+The link:config-gerrit.html#auth.maxAuthTokenLifetime[maximum lifetime]
+of authentication tokens.
|==========================================
[[cache-info]]
@@ -2733,6 +2764,16 @@
cleanup
|=============================
+[[migrate-passwords-to-tokens-input]]
+=== MigratePasswordsToTokens.Input
+The `MigratePasswordsToTokens.Input` entity is being used to configure the
+migration of HTTP passwords to auth tokens.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name||Description
+|`lifetime`||Lifetime of the migrated token.
+|=============================
GERRIT
------
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index a907e28..287ccb2 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -33,10 +33,10 @@
results to correspond to what anonymous users can read (which may
be nothing at all).
-Users (and programs) can authenticate with HTTP passwords by prefixing
+Users (and programs) can authenticate with authentication tokens by prefixing
the endpoint URL with `/a/`. For example to authenticate to
`/projects/`, request the URL `/a/projects/`. Gerrit will use HTTP basic
-authentication with the HTTP password from the user's account settings
+authentication with the tokens from the user's account settings
page. This form of authentication bypasses the need for XSRF tokens.
An authorization cookie may be presented in the request URL inside the
diff --git a/Documentation/user-request-cancellation-and-deadlines.txt b/Documentation/user-request-cancellation-and-deadlines.txt
index c39854c..487bc13 100644
--- a/Documentation/user-request-cancellation-and-deadlines.txt
+++ b/Documentation/user-request-cancellation-and-deadlines.txt
@@ -152,7 +152,7 @@
|=======================
|Request Type |Cancellation Reason|Response
|REST over HTTP |Client Disconnected|The response is '499 Client Closed Request'.
-| |Server-side deadline exceeded|The response is '408 Server Deadline Exceeded'.
+| |Server-side deadline exceeded|The response is '500 Internal Server Error'.
| |Client-provided deadline exceeded|The response is '408 Client Provided Deadline Exceeded'.
|SSH command |Client Disconnected|The error message is 'Client Closed Request'.
| |Server-side deadline exceeded|The error message is 'Server Deadline Exceeded'.
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index c5bee19..5645895 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -39,14 +39,13 @@
and produce unwanted load to the LDAP server.
When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can
-be regenerated by going to `Settings`, and then accessing the `HTTP
-Password` tab. Revocation can effectively be done by regenerating the
-password and then forgetting it.
+be managed by going to `Settings`, and then navigating to the `HTTP
+Credentials` section.
For Gerrit installations where an link:config-gerrit.html#auth.httpPasswordUrl[HTTP password URL]
is configured, the password can be obtained by clicking on `Obtain Password`
and then following the site-specific instructions. On sites where this URL is
-not configured, the password can be obtained by clicking on `Generate Password`.
+not configured, a token can be obtained by clicking on `Generate Password`.
[[ssh]]
== SSH
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 8654f82..c93c5ad 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -127,6 +127,17 @@
return account;
}
+ public synchronized void delete(String username) throws IOException, ConfigInvalidException {
+ accountsUpdateProvider.get().delete("Delete test account", accounts.get(username).id());
+ }
+
+ public synchronized TestAccount recreate(String username) throws Exception {
+ TestAccount account = accounts.get(username);
+ delete(account.username());
+ evict(account.id());
+ return create(account.username(), account.email(), account.fullName(), account.displayName());
+ }
+
protected void addUserToGroups(Account.Id id, String... groupNames) throws Exception {
if (groupNames != null) {
for (String n : groupNames) {
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index b3f685d..8ea4561 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -25,6 +25,7 @@
import com.google.errorprone.annotations.InlineMe;
import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -294,6 +295,30 @@
@ConvertibleToProto
public abstract static class Conflicts {
/**
+ * The SHA1 of the commit that was used as the base commit for the Git merge that created the
+ * revision.
+ *
+ * <p>A base is not set if:
+ *
+ * <ul>
+ * <li>the merged commits do not have a common ancestor (in this case {@link #noBaseReason()}
+ * is {@link NoMergeBaseReason#NO_COMMON_ANCESTOR}).
+ * <li>the merged commits have multiple merge bases (happens for criss-cross-merges) and the
+ * base was computed (in this case {@link #noBaseReason()} is {@link
+ * NoMergeBaseReason#COMPUTED_BASE}).
+ * <li>a one sided merge strategy (e.g. {@code ours} or {@code theirs}) has been used and
+ * computing a base was not required for the merge (in this case {@link #noBaseReason()}
+ * is {@link NoMergeBaseReason#ONE_SIDED_MERGE_STRATEGY}).
+ * <li>the revision was not created by performing a Git merge operation (in this case {@link
+ * #noBaseReason()} is {@link NoMergeBaseReason#NO_MERGE_PERFORMED}).
+ * <li>the revision has been created before Gerrit started to store the base for conflicts (in
+ * this case {@link #noBaseReason()} is {@link
+ * NoMergeBaseReason#HISTORIC_DATA_WITHOUT_BASE}).
+ * </ul>
+ */
+ public abstract Optional<ObjectId> base();
+
+ /**
* The SHA1 of the commit that was used as {@code ours} for the Git merge that created the
* revision.
*
@@ -314,6 +339,35 @@
public abstract Optional<ObjectId> theirs();
/**
+ * The merge strategy was used for the Git merge that created the revision.
+ *
+ * <p>Possible values: {@code resolve}, {@code recursive}, {@code simple-two-way-in-core},
+ * {@code ours} and {@code theirs}.
+ */
+ public abstract Optional<String> mergeStrategy();
+
+ /**
+ * Reason why {@link #base()} is not set.
+ *
+ * <p>Only set if {@link #base()} is not set.
+ *
+ * <p>Possible values are:
+ *
+ * <ul>
+ * <li>{@code NO_COMMON_ANCESTOR}: The merged commits do not have a common ancestor.
+ * <li>{@code COMPUTED_BASE}: The merged commits have multiple merge bases (happens for
+ * criss-cross-merges) and the base was computed.
+ * <li>{@code ONE_SIDED_MERGE_STRATEGY}: A one sided merge strategy (e.g. {@code ours} or
+ * {@code theirs}) has been used and computing a base was not required for the merge.
+ * <li>{@code NO_MERGE_PERFORMED}: The revision was not created by performing a Git merge
+ * operation.
+ * <li>{@code HISTORIC_DATA_WITHOUT_BASE}: The revision has been created before Gerrit started
+ * to store the base for conflicts.
+ * </ul>
+ */
+ public abstract Optional<NoMergeBaseReason> noBaseReason();
+
+ /**
* Whether any of the files in the revision has a conflict due to merging {@link #ours} and
* {@link #theirs}.
*
@@ -327,8 +381,14 @@
public abstract boolean containsConflicts();
public static Conflicts create(
- Optional<ObjectId> ours, Optional<ObjectId> theirs, boolean containsConflicts) {
- return new AutoValue_PatchSet_Conflicts(ours, theirs, containsConflicts);
+ Optional<ObjectId> base,
+ Optional<ObjectId> ours,
+ Optional<ObjectId> theirs,
+ Optional<String> mergeStrategy,
+ Optional<NoMergeBaseReason> noBaseReason,
+ boolean containsConflicts) {
+ return new AutoValue_PatchSet_Conflicts(
+ base, ours, theirs, mergeStrategy, noBaseReason, containsConflicts);
}
}
}
diff --git a/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
index c073a5f..15c18ba 100644
--- a/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
@@ -18,8 +18,10 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.proto.Entities;
import com.google.protobuf.Parser;
+import java.util.stream.Collectors;
/**
* Proto converter between {@link AccountInput} and {@link
@@ -30,6 +32,9 @@
implements ProtoConverter<Entities.AccountInput, AccountInput> {
INSTANCE;
+ private final ProtoConverter<Entities.AuthTokenInput, AuthTokenInput> tokenInputConverter =
+ TokenInputProtoConverter.INSTANCE;
+
@Override
public Entities.AccountInput toProto(AccountInput accountInput) {
Entities.AccountInput.Builder builder = Entities.AccountInput.newBuilder();
@@ -54,6 +59,12 @@
if (accountInput.groups != null) {
builder.addAllGroups(accountInput.groups);
}
+ if (accountInput.tokens != null) {
+ builder.addAllTokens(
+ accountInput.tokens.stream()
+ .map(tokenInputConverter::toProto)
+ .collect(Collectors.toList()));
+ }
return builder.build();
}
@@ -82,6 +93,12 @@
if (proto.getGroupsCount() > 0) {
accountInput.groups = proto.getGroupsList();
}
+ if (proto.getTokensCount() > 0) {
+ accountInput.tokens =
+ proto.getTokensList().stream()
+ .map(tokenInputConverter::fromProto)
+ .collect(Collectors.toList());
+ }
return accountInput;
}
diff --git a/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java b/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java
index c7dd9e2..298fe5b 100644
--- a/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java
@@ -16,6 +16,7 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.proto.Entities;
import com.google.protobuf.Parser;
import java.util.Optional;
@@ -32,20 +33,35 @@
@Override
public Entities.Conflicts toProto(PatchSet.Conflicts conflicts) {
Entities.Conflicts.Builder builder = Entities.Conflicts.newBuilder();
+ conflicts.base().ifPresent(base -> builder.setBase(objectIdConverter.toProto(base)));
conflicts.ours().ifPresent(ours -> builder.setOurs(objectIdConverter.toProto(ours)));
conflicts.theirs().ifPresent(theirs -> builder.setTheirs(objectIdConverter.toProto(theirs)));
+ conflicts.mergeStrategy().ifPresent(mergeStrategy -> builder.setMergeStrategy(mergeStrategy));
+ conflicts
+ .noBaseReason()
+ .ifPresent(
+ noBaseReason ->
+ builder.setNoBaseReason(
+ Entities.NoMergeBaseReason.forNumber(noBaseReason.getValue())));
return builder.setContainsConflicts(conflicts.containsConflicts()).build();
}
@Override
public PatchSet.Conflicts fromProto(Entities.Conflicts proto) {
return PatchSet.Conflicts.create(
+ proto.hasBase()
+ ? Optional.of(objectIdConverter.fromProto(proto.getBase()))
+ : Optional.empty(),
proto.hasOurs()
? Optional.of(objectIdConverter.fromProto(proto.getOurs()))
: Optional.empty(),
proto.hasTheirs()
? Optional.of(objectIdConverter.fromProto(proto.getTheirs()))
: Optional.empty(),
+ proto.hasMergeStrategy() ? Optional.of(proto.getMergeStrategy()) : Optional.empty(),
+ proto.hasNoBaseReason()
+ ? Optional.of(NoMergeBaseReason.valueOf(proto.getNoBaseReason().name()))
+ : Optional.empty(),
proto.hasContainsConflicts() ? proto.getContainsConflicts() : false);
}
diff --git a/java/com/google/gerrit/entities/converter/TokenInputProtoConverter.java b/java/com/google/gerrit/entities/converter/TokenInputProtoConverter.java
new file mode 100644
index 0000000..713c322
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/TokenInputProtoConverter.java
@@ -0,0 +1,63 @@
+/*
+ * 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.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link AuthTokenInput} and {@link
+ * com.google.gerrit.proto.Entities.AuthTokenInput}.
+ */
+@Immutable
+public enum TokenInputProtoConverter
+ implements ProtoConverter<Entities.AuthTokenInput, AuthTokenInput> {
+ INSTANCE;
+
+ @Override
+ public Entities.AuthTokenInput toProto(AuthTokenInput tokenInput) {
+ Entities.AuthTokenInput.Builder builder = Entities.AuthTokenInput.newBuilder();
+ if (tokenInput.id != null) {
+ builder.setId(tokenInput.id);
+ }
+ if (tokenInput.token != null) {
+ builder.setToken(tokenInput.token);
+ }
+
+ return builder.build();
+ }
+
+ @Override
+ public AuthTokenInput fromProto(Entities.AuthTokenInput proto) {
+ AuthTokenInput tokenInput = new AuthTokenInput();
+ if (proto.hasId()) {
+ tokenInput.id = proto.getId();
+ }
+ if (proto.hasToken()) {
+ tokenInput.token = proto.getToken();
+ }
+
+ return tokenInput;
+ }
+
+ @Override
+ public Parser<Entities.AuthTokenInput> getParser() {
+ return Entities.AuthTokenInput.parser();
+ }
+}
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index a68307a..2d260ea 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -15,6 +15,8 @@
package com.google.gerrit.extensions.api.accounts;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.EditPreferencesInfo;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -118,11 +120,17 @@
void setName(String name) throws RestApiException;
+ @CanIgnoreReturnValue
+ AuthTokenInfo createToken(AuthTokenInput input) throws RestApiException;
+
+ List<AuthTokenInfo> getTokens() throws RestApiException;
+
/**
* Generate a new HTTP password.
*
* @return the generated password.
*/
+ @Deprecated
String generateHttpPassword() throws RestApiException;
/**
@@ -134,6 +142,7 @@
* @return the new password, {@code null} if the password was removed.
*/
@CanIgnoreReturnValue
+ @Deprecated
String setHttpPassword(String httpPassword) throws RestApiException;
void delete() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountInput.java b/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
index 2bcce30..675c8c9 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
@@ -14,6 +14,7 @@
package com.google.gerrit.extensions.api.accounts;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.restapi.DefaultInput;
import java.util.List;
@@ -23,6 +24,7 @@
public String displayName;
public String email;
public String sshKey;
- public String httpPassword;
+ @Deprecated public String httpPassword;
+ public List<AuthTokenInput> tokens;
public List<String> groups;
}
diff --git a/java/com/google/gerrit/extensions/auth/AuthTokenInfo.java b/java/com/google/gerrit/extensions/auth/AuthTokenInfo.java
new file mode 100644
index 0000000..5743da7
--- /dev/null
+++ b/java/com/google/gerrit/extensions/auth/AuthTokenInfo.java
@@ -0,0 +1,25 @@
+// 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.auth;
+
+import com.google.gerrit.common.Nullable;
+import java.sql.Timestamp;
+
+public class AuthTokenInfo {
+ public String id;
+ /* Should only be set when the token is being created. */
+ @Nullable public String token;
+ @Nullable public Timestamp expiration;
+}
diff --git a/java/com/google/gerrit/extensions/auth/AuthTokenInput.java b/java/com/google/gerrit/extensions/auth/AuthTokenInput.java
new file mode 100644
index 0000000..c7e5f6e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/auth/AuthTokenInput.java
@@ -0,0 +1,42 @@
+// 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.auth;
+
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.gerrit.common.Nullable;
+import java.util.Objects;
+
+@ConvertibleToProto
+public class AuthTokenInput {
+ @Nullable public String id;
+ @Nullable public String token;
+ @Nullable public String lifetime;
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, token, lifetime);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof AuthTokenInput)) {
+ return false;
+ }
+ AuthTokenInput other = (AuthTokenInput) obj;
+ return Objects.equals(id, other.id)
+ && Objects.equals(token, other.token)
+ && Objects.equals(lifetime, other.lifetime);
+ }
+}
diff --git a/java/com/google/gerrit/extensions/common/AuthInfo.java b/java/com/google/gerrit/extensions/common/AuthInfo.java
index 3aa40fc..38b8b4e 100644
--- a/java/com/google/gerrit/extensions/common/AuthInfo.java
+++ b/java/com/google/gerrit/extensions/common/AuthInfo.java
@@ -119,4 +119,11 @@
* <p>Only set if authentication type is {@code LDAP}, {@code LDAP_BIND} or {@code OAUTH}.
*/
public GitBasicAuthPolicy gitBasicAuthPolicy;
+
+ /**
+ * The maximum lifetime allowed for authentication tokens in minutes.
+ *
+ * <p>The value of the {@code auth.maxAuthTokenLifetime} parameter in {@code gerrit.config}.
+ */
+ public long maxTokenLifetime;
}
diff --git a/java/com/google/gerrit/extensions/common/ConflictsInfo.java b/java/com/google/gerrit/extensions/common/ConflictsInfo.java
index ba9f1be..8e623d3 100644
--- a/java/com/google/gerrit/extensions/common/ConflictsInfo.java
+++ b/java/com/google/gerrit/extensions/common/ConflictsInfo.java
@@ -17,6 +17,29 @@
/** Information about conflicts in a revision. */
public class ConflictsInfo {
/**
+ * The SHA1 of the commit that was used as the base commit for the Git merge that created the
+ * revision.
+ *
+ * <p>A base is not set if:
+ *
+ * <ul>
+ * <li>the merged commits do not have a common ancestor (in this case {@link #noBaseReason} is
+ * {@link NoMergeBaseReason#NO_COMMON_ANCESTOR}).
+ * <li>the merged commits have multiple merge bases (happens for criss-cross-merges) and the
+ * base was computed (in this case {@link #noBaseReason} is {@link
+ * NoMergeBaseReason#COMPUTED_BASE}).
+ * <li>a one sided merge strategy (e.g. {@code ours} or {@code theirs}) has been used and
+ * computing a base was not required for the merge (in this case {@link #noBaseReason} is
+ * {@link NoMergeBaseReason#ONE_SIDED_MERGE_STRATEGY}).
+ * <li>the revision was not created by performing a Git merge operation (in this case {@link
+ * #noBaseReason} is {@link NoMergeBaseReason#NO_MERGE_PERFORMED}).
+ * <li>the revision has been created before Gerrit started to store the base for conflicts (in
+ * this case {@link #noBaseReason} is {@link NoMergeBaseReason#HISTORIC_DATA_WITHOUT_BASE}).
+ * </ul>
+ */
+ public String base;
+
+ /**
* The SHA1 of the commit that was used as {@code ours} for the Git merge that created the
* revision.
*
@@ -37,6 +60,35 @@
public String theirs;
/**
+ * The merge strategy was used for the Git merge that created the revision.
+ *
+ * <p>Possible values: {@code resolve}, {@code recursive}, {@code simple-two-way-in-core}, {@code
+ * ours} and {@code theirs}.
+ */
+ public String mergeStrategy;
+
+ /**
+ * Reason why {@link #base} is not set.
+ *
+ * <p>Only set if {@link #base} is not set.
+ *
+ * <p>Possible values are:
+ *
+ * <ul>
+ * <li>{@code NO_COMMON_ANCESTOR}: The merged commits do not have a common ancestor.
+ * <li>{@code COMPUTED_BASE}: The merged commits have multiple merge bases (happens for
+ * criss-cross-merges) and the base was computed.
+ * <li>{@code ONE_SIDED_MERGE_STRATEGY}: A one sided merge strategy (e.g. {@code ours} or {@code
+ * theirs}) has been used and computing a base was not required for the merge.
+ * <li>{@code NO_MERGE_PERFORMED}: The revision was not created by performing a Git merge
+ * operation.
+ * <li>{@code HISTORIC_DATA_WITHOUT_BASE}: The revision has been created before Gerrit started
+ * to store the base for conflicts.
+ * </ul>
+ */
+ public NoMergeBaseReason noBaseReason;
+
+ /**
* Whether any of the files in the revision has a conflict due to merging {@link #ours} and {@link
* #theirs}.
*
diff --git a/java/com/google/gerrit/extensions/common/HttpPasswordInput.java b/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
index 246c7cf..d45b23f 100644
--- a/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
+++ b/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
@@ -14,6 +14,7 @@
package com.google.gerrit.extensions.common;
+@Deprecated
public class HttpPasswordInput {
public String httpPassword;
public boolean generate;
diff --git a/java/com/google/gerrit/extensions/common/NoMergeBaseReason.java b/java/com/google/gerrit/extensions/common/NoMergeBaseReason.java
new file mode 100644
index 0000000..840fd4f
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/NoMergeBaseReason.java
@@ -0,0 +1,58 @@
+// 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;
+
+import com.google.common.base.CaseFormat;
+
+/** Reasons why a merge base is not available. */
+public enum NoMergeBaseReason {
+ /**
+ * The revision has been created before Gerrit started to compute and store the base for
+ * conflicts.
+ */
+ HISTORIC_DATA_WITHOUT_BASE(0),
+
+ /** The merged commits do not have a common ancestor. */
+ NO_COMMON_ANCESTOR(1),
+
+ /**
+ * The merged commits have multiple merge bases (happens for criss-cross-merges) and the base was
+ * computed.
+ */
+ COMPUTED_BASE(2),
+
+ /**
+ * A one sided merge strategy (e.g. {@code ours} or {@code theirs}) has been used and computing a
+ * base was not required for the merge.
+ */
+ ONE_SIDED_MERGE_STRATEGY(3),
+
+ /** The revision was not created by performing a Git merge operation. */
+ NO_MERGE_PERFORMED(4);
+
+ private final int value;
+
+ NoMergeBaseReason(int v) {
+ this.value = v;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public String getDescription() {
+ return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, name()).replace('-', ' ');
+ }
+}
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index b0c7615..5ef7434 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -33,8 +33,8 @@
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.AuthTokenVerifier;
import com.google.gerrit.server.account.AuthenticationFailedException;
-import com.google.gerrit.server.account.externalids.PasswordVerifier;
import com.google.gerrit.server.auth.AuthenticationUnavailableException;
import com.google.gerrit.server.auth.NoSuchUserException;
import com.google.gerrit.server.config.AuthConfig;
@@ -77,7 +77,7 @@
private final AccountManager accountManager;
private final AuthConfig authConfig;
private final AuthRequest.Factory authRequestFactory;
- private final PasswordVerifier passwordVerifier;
+ private final AuthTokenVerifier tokenVerifier;
@Inject
ProjectBasicAuthFilter(
@@ -86,13 +86,13 @@
AccountManager accountManager,
AuthConfig authConfig,
AuthRequest.Factory authRequestFactory,
- PasswordVerifier passwordVerifier) {
+ AuthTokenVerifier tokenVerifier) {
this.session = session;
this.accountCache = accountCache;
this.accountManager = accountManager;
this.authConfig = authConfig;
this.authRequestFactory = authRequestFactory;
- this.passwordVerifier = passwordVerifier;
+ this.tokenVerifier = tokenVerifier;
}
@Override
@@ -161,7 +161,7 @@
GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
|| gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
- if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
+ if (tokenVerifier.checkToken(who.account().id(), password)) {
logger.atFine().log(
"HTTP:%s %s username/password authentication succeeded",
req.getMethod(), req.getRequestURI());
@@ -183,7 +183,7 @@
"HTTP:%s %s Realm authentication succeeded", req.getMethod(), req.getRequestURI());
return true;
} catch (NoSuchUserException e) {
- if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
+ if (tokenVerifier.checkToken(who.account().id(), password)) {
return succeedAuthentication(who, null);
}
logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 64b69d1..bef75b0 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -20,6 +20,7 @@
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.auth.AuthModule;
import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
import com.google.gerrit.gpg.GpgModule;
import com.google.gerrit.httpd.AllRequestFilter;
import com.google.gerrit.httpd.GerritAuthModule;
@@ -53,6 +54,8 @@
import com.google.gerrit.server.StartupChecks.StartupChecksModule;
import com.google.gerrit.server.account.AccountCacheImpl;
import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
+import com.google.gerrit.server.account.AuthTokenModule;
+import com.google.gerrit.server.account.CachingAuthTokenModule;
import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
import com.google.gerrit.server.api.GerritApiModule;
@@ -157,6 +160,7 @@
private Injector dbInjector;
private Injector cfgInjector;
private Config config;
+ private AuthConfig authConfig;
private Injector sysInjector;
private Injector webInjector;
private Injector sshInjector;
@@ -222,6 +226,7 @@
dbInjector = createDbInjector();
initIndexType();
config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+ authConfig = cfgInjector.getInstance(AuthConfig.class);
sysInjector = createSysInjector();
if (!sshdOff()) {
sshInjector = createSshInjector();
@@ -359,6 +364,16 @@
SshSessionFactoryInitializer.init();
modules.add(SshKeyCacheImpl.module());
+
+ boolean useAuthTokenCache =
+ authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP
+ || authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP_LDAP;
+ if (useAuthTokenCache) {
+ modules.add(new CachingAuthTokenModule());
+ } else {
+ modules.add(new AuthTokenModule());
+ }
+
modules.add(
new AbstractModule() {
@Override
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 871ec78..5eb600d 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -20,7 +20,6 @@
import static com.google.common.net.HttpHeaders.IF_NONE_MATCH;
import static com.google.common.net.HttpHeaders.LAST_MODIFIED;
import static java.util.Objects.requireNonNull;
-import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
@@ -200,14 +199,7 @@
return;
}
- String e = req.getParameter("e");
- if (e != null && !r.etag.equals(e)) {
- CacheHeaders.setNotCacheable(rsp);
- rsp.setStatus(SC_NOT_FOUND);
- return;
- } else if (!requiresPostProcess
- && cacheOnClient
- && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
+ if (!requiresPostProcess && cacheOnClient && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
rsp.setStatus(SC_NOT_MODIFIED);
return;
}
@@ -230,11 +222,7 @@
CacheHeaders.setNotCacheable(rsp);
}
if (!CacheHeaders.hasCacheHeader(rsp)) {
- if (e != null && r.etag.equals(e)) {
- CacheHeaders.setCacheable(req, rsp, 360, DAYS, false);
- } else {
- CacheHeaders.setCacheable(req, rsp, 15, MINUTES, refresh);
- }
+ CacheHeaders.setCacheable(req, rsp, 15, MINUTES, refresh);
}
rsp.setContentType(r.contentType);
rsp.setContentLength(tosend.length);
diff --git a/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
index 01b89cf..5f5218b 100644
--- a/java/com/google/gerrit/metrics/Field.java
+++ b/java/com/google/gerrit/metrics/Field.java
@@ -155,8 +155,7 @@
}
}
- private static final Pattern SUBMETRIC_NAME_PATTERN =
- Pattern.compile("[a-zA-Z0-9_-]+([a-zA-Z0-9_-]+)*");
+ private static final Pattern SUBMETRIC_NAME_PATTERN = Pattern.compile("[a-zA-Z0-9_-]+");
private static final Pattern INVALID_CHAR_PATTERN = Pattern.compile("[^\\w-]");
private static final String REPLACEMENT_PREFIX = "_0x";
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 7564277..b3e4dfc 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -24,6 +24,7 @@
import com.google.gerrit.auth.AuthModule;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
import com.google.gerrit.gpg.GpgModule;
import com.google.gerrit.httpd.AllRequestFilter;
import com.google.gerrit.httpd.GerritAuthModule;
@@ -62,6 +63,8 @@
import com.google.gerrit.server.StartupChecks.StartupChecksModule;
import com.google.gerrit.server.account.AccountCacheImpl;
import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
+import com.google.gerrit.server.account.AuthTokenModule;
+import com.google.gerrit.server.account.CachingAuthTokenModule;
import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl;
import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
@@ -221,6 +224,7 @@
private Injector cfgInjector;
private Config config;
private LogConfig logConfig;
+ private AuthConfig authConfig;
private Injector sysInjector;
private Injector sshInjector;
private Injector webInjector;
@@ -407,6 +411,7 @@
}
cfgInjector = createCfgInjector();
config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+ authConfig = cfgInjector.getInstance(AuthConfig.class);
indexType = IndexModule.getIndexType(cfgInjector);
sysInjector = createSysInjector();
sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
@@ -562,6 +567,15 @@
} else {
modules.add(NoSshKeyCache.module());
}
+ boolean useAuthTokenCache =
+ authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP
+ || authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP_LDAP;
+ if (useAuthTokenCache) {
+ modules.add(new CachingAuthTokenModule());
+ } else {
+ modules.add(new AuthTokenModule());
+ }
+
modules.add(
new AbstractModule() {
@Override
diff --git a/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java b/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java
new file mode 100644
index 0000000..af9a153
--- /dev/null
+++ b/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java
@@ -0,0 +1,129 @@
+// 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;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.lucene.LuceneIndexModule;
+import com.google.gerrit.pgm.init.VersionedAuthTokensOnInit;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InstallAllPlugins;
+import com.google.gerrit.pgm.init.api.InstallPlugins;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.util.BatchProgramModule;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AuthTokenCache;
+import com.google.gerrit.server.account.AuthTokenModule;
+import com.google.gerrit.server.account.PasswordMigrator;
+import com.google.gerrit.server.account.VersionedAuthTokens;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbWriteStorageModule;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.index.options.AutoFlush;
+import com.google.gerrit.server.index.project.ProjectIndexerImpl;
+import com.google.gerrit.server.notedb.NoteDbDraftCommentsModule;
+import com.google.gerrit.server.notedb.NoteDbStarredChangesModule;
+import com.google.gerrit.server.project.DefaultLockManager;
+import com.google.gerrit.server.project.LockManager;
+import com.google.gerrit.server.restapi.account.CreateToken;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.TypeLiteral;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.kohsuke.args4j.Option;
+
+/** Converts HTTP passwords for all accounts to tokens */
+public class MigratePasswordsToTokens extends SiteProgram {
+ private final LifecycleManager manager = new LifecycleManager();
+
+ private Optional<Instant> lifetime = Optional.empty();
+
+ @Inject private PasswordMigrator.Factory passwordMigratorFactory;
+
+ @Option(name = "--lifetime", usage = "The lifetime of migrated tokens.")
+ public void setDefaultLifetime(String value) throws BadRequestException {
+ lifetime = CreateToken.getExpirationInstant(value, Optional.empty());
+ }
+
+ @Override
+ public int run() throws Exception {
+ mustHaveValidSite();
+ ConsoleUI ui = ConsoleUI.getInstance();
+
+ Injector dbInjector = createDbInjector();
+ manager.add(dbInjector, dbInjector.createChildInjector(NoteDbSchemaVersionCheck.module()));
+ manager.start();
+ dbInjector
+ .createChildInjector(
+ new FactoryModule() {
+ @Override
+ protected void configure() {
+ bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+ bind(ConsoleUI.class).toInstance(ui);
+ bind(Boolean.class)
+ .annotatedWith(InstallAllPlugins.class)
+ .toInstance(Boolean.FALSE);
+ bind(new TypeLiteral<List<String>>() {})
+ .annotatedWith(InstallPlugins.class)
+ .toInstance(new ArrayList<>());
+ bind(LockManager.class).toInstance(new DefaultLockManager());
+
+ factory(PasswordMigrator.Factory.class);
+ factory(MetaDataUpdate.InternalFactory.class);
+ factory(VersionedAuthTokens.Factory.class);
+ factory(VersionedAuthTokensOnInit.Factory.class);
+ factory(Section.Factory.class);
+ factory(MultiProgressMonitor.Factory.class);
+ factory(ProjectIndexerImpl.Factory.class);
+ factory(ChangeResource.Factory.class);
+ bind(IdentifiedUser.GenericFactory.class);
+
+ install(new ExternalIdNoteDbReadStorageModule());
+ install(new ExternalIdNoteDbWriteStorageModule());
+ install(new ExternalIdCacheImpl.ExternalIdCacheModule());
+ install(new ExternalIdCacheImpl.ExternalIdCacheBindingModule());
+ install(new DefaultRefLogIdentityProvider.Module());
+ install(new BatchProgramModule(dbInjector, ImmutableSet.of()));
+ install(LuceneIndexModule.latestVersion(false, AutoFlush.ENABLED));
+ install(new AccountNoteDbReadStorageModule());
+ install(new NoteDbStarredChangesModule());
+ install(new NoteDbDraftCommentsModule());
+ install(new WorkQueueModule());
+ install(AuthTokenCache.module());
+ install(new AuthTokenModule());
+ }
+ })
+ .injectMembers(this);
+
+ passwordMigratorFactory.create(lifetime).run();
+
+ manager.stop();
+ return 0;
+ }
+}
diff --git a/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java b/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java
new file mode 100644
index 0000000..a3678a0
--- /dev/null
+++ b/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java
@@ -0,0 +1,137 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.init.VersionedAuthTokensOnInit;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InstallAllPlugins;
+import com.google.gerrit.pgm.init.api.InstallPlugins;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.account.VersionedAuthTokens;
+import com.google.gerrit.server.account.externalids.storage.notedb.DisabledExternalIdCache;
+import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbImpl;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.restapi.account.CreateToken;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.kohsuke.args4j.Option;
+
+/** Reduces token lifetime if they exceed a given max lifetime */
+public class ReduceMaxTokenLifetime extends SiteProgram {
+ private final LifecycleManager manager = new LifecycleManager();
+ private final TextProgressMonitor monitor = new TextProgressMonitor();
+
+ private Optional<Instant> lifetime = Optional.empty();
+
+ @Inject private GitRepositoryManager repoManager;
+ @Inject private AllUsersName allUsersName;
+ @Inject private AccountsNoteDbImpl accounts;
+ @Inject private VersionedAuthTokensOnInit.Factory tokenFactory;
+
+ @Option(name = "--lifetime", usage = "The lifetime of migrated tokens.", required = true)
+ public void setMaxLifetime(String value) throws BadRequestException {
+ lifetime = CreateToken.getExpirationInstant(value, Optional.empty());
+ }
+
+ @Override
+ public int run() throws Exception {
+ mustHaveValidSite();
+ ConsoleUI ui = ConsoleUI.getInstance();
+
+ Injector dbInjector = createDbInjector();
+ manager.add(dbInjector, dbInjector.createChildInjector(NoteDbSchemaVersionCheck.module()));
+ manager.start();
+ dbInjector
+ .createChildInjector(
+ new FactoryModule() {
+ @Override
+ protected void configure() {
+ bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+ bind(ConsoleUI.class).toInstance(ui);
+ bind(Boolean.class)
+ .annotatedWith(InstallAllPlugins.class)
+ .toInstance(Boolean.FALSE);
+ bind(new TypeLiteral<List<String>>() {})
+ .annotatedWith(InstallPlugins.class)
+ .toInstance(new ArrayList<>());
+
+ factory(MetaDataUpdate.InternalFactory.class);
+ factory(VersionedAuthTokens.Factory.class);
+ factory(VersionedAuthTokensOnInit.Factory.class);
+ factory(Section.Factory.class);
+
+ install(DisabledExternalIdCache.module());
+ }
+ })
+ .injectMembers(this);
+
+ monitor.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
+ Set<Account.Id> todo = accounts.allIds();
+ monitor.endTask();
+
+ monitor.beginTask("Adapting token lifetime", todo.size());
+ try (Repository repo = repoManager.openRepository(allUsersName)) {
+ for (Account.Id accountId : todo) {
+ adaptTokenLifetime(accountId, lifetime.orElse(Instant.MAX));
+ monitor.update(1);
+ }
+ }
+ monitor.endTask();
+
+ manager.stop();
+ return 0;
+ }
+
+ private void adaptTokenLifetime(Account.Id accountId, Instant maxAllowedExpirationInstant)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ VersionedAuthTokensOnInit authTokens = tokenFactory.create(accountId).load();
+ boolean updated = false;
+ for (AuthToken authToken : authTokens.getTokens()) {
+ if (authToken.expirationDate().isEmpty()
+ || authToken.expirationDate().get().isAfter(maxAllowedExpirationInstant)) {
+ AuthToken updatedToken =
+ AuthToken.create(
+ authToken.id(), authToken.hashedToken(), Optional.of(maxAllowedExpirationInstant));
+ authTokens.updateToken(updatedToken);
+ updated = true;
+ }
+ }
+ if (updated) {
+ authTokens.save("Updated token lifetime");
+ }
+ }
+}
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 1b393db..a657ad1 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -50,6 +50,7 @@
private final ConsoleUI ui;
private final AccountsOnInit accounts;
private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
+ private final VersionedAuthTokensOnInit.Factory tokenFactory;
private final ExternalIdsOnInit externalIds;
private final SequencesOnInit sequencesOnInit;
private final GroupsOnInit groupsOnInit;
@@ -63,6 +64,7 @@
ConsoleUI ui,
AccountsOnInit accounts,
VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
+ VersionedAuthTokensOnInit.Factory tokenFactory,
ExternalIdsOnInit externalIds,
SequencesOnInit sequencesOnInit,
GroupsOnInit groupsOnInit,
@@ -71,6 +73,7 @@
this.ui = ui;
this.accounts = accounts;
this.authorizedKeysFactory = authorizedKeysFactory;
+ this.tokenFactory = tokenFactory;
this.externalIds = externalIds;
this.sequencesOnInit = sequencesOnInit;
this.groupsOnInit = groupsOnInit;
@@ -106,12 +109,12 @@
Account.Id id = Account.id(sequencesOnInit.nextAccountId());
String username = ui.readString("admin", "username");
String name = ui.readString("Administrator", "name");
- String httpPassword = ui.readString("secret", "HTTP password");
+ String token = ui.readString("secret", "Authentication token");
AccountSshKey sshKey = readSshKey(id);
String email = readEmail(sshKey);
List<ExternalId> extIds = new ArrayList<>(2);
- extIds.add(externalIdFactory.createUsername(username, id, httpPassword));
+ extIds.add(externalIdFactory.createUsername(username, id));
if (email != null) {
extIds.add(externalIdFactory.createEmail(id, email));
@@ -134,6 +137,10 @@
GroupReference adminGroup = adminGroupReference.get();
groupsOnInit.addGroupMember(adminGroup.getUUID(), persistedAccount);
+ VersionedAuthTokensOnInit authTokens = tokenFactory.create(id).load();
+ authTokens.addToken("initialToken", token, Optional.empty());
+ authTokens.save("Add token for initial admin user\n");
+
if (sshKey != null) {
VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
authorizedKeys.addKey(sshKey.sshPublicKey());
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index f36ec3d..2b5abc8 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -50,6 +50,7 @@
.toProvider(DisabledGitRefUpdatedRepoAccountsSequenceProvider.class);
factory(Section.Factory.class);
factory(VersionedAuthorizedKeysOnInit.Factory.class);
+ factory(VersionedAuthTokensOnInit.Factory.class);
// Steps are executed in the order listed here.
//
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index d0d03b5..a591121 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -109,6 +109,12 @@
extractMailExample("AddKeyHtml.soy");
extractMailExample("AddToAttentionSet.soy");
extractMailExample("AddToAttentionSetHtml.soy");
+ extractMailExample("AuthTokenExpired.soy");
+ extractMailExample("AuthTokenExpiredHtml.soy");
+ extractMailExample("AuthTokenWillExpire.soy");
+ extractMailExample("AuthTokenWillExpireHtml.soy");
+ extractMailExample("AuthTokenUpdate.soy");
+ extractMailExample("AuthTokenUpdateHtml.soy");
extractMailExample("ChangeFooter.soy");
extractMailExample("ChangeFooterHtml.soy");
extractMailExample("ChangeSubject.soy");
diff --git a/java/com/google/gerrit/pgm/init/VersionedAuthTokensOnInit.java b/java/com/google/gerrit/pgm/init/VersionedAuthTokensOnInit.java
new file mode 100644
index 0000000..a112987
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/VersionedAuthTokensOnInit.java
@@ -0,0 +1,113 @@
+// 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.init;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Strings;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.account.VersionedAuthTokens;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+
+public class VersionedAuthTokensOnInit extends VersionedMetaDataOnInit {
+ public interface Factory {
+ VersionedAuthTokensOnInit create(Account.Id accountId);
+ }
+
+ private Map<String, AuthToken> tokens;
+
+ @Inject
+ public VersionedAuthTokensOnInit(
+ AllUsersNameOnInitProvider allUsers,
+ SitePaths site,
+ InitFlags flags,
+ @Assisted Account.Id accountId) {
+ super(flags, site, allUsers.get(), RefNames.refsUsers(accountId));
+ }
+
+ @Override
+ public VersionedAuthTokensOnInit load() throws IOException, ConfigInvalidException {
+ super.load();
+ return this;
+ }
+
+ @Override
+ protected void onLoad() throws IOException, ConfigInvalidException {
+ tokens = VersionedAuthTokens.parse(readUTF8(VersionedAuthTokens.FILE_NAME));
+ }
+
+ @CanIgnoreReturnValue
+ public AuthToken addToken(String id, String t, Optional<Instant> expirationDate)
+ throws InvalidAuthTokenException {
+ checkState(tokens != null, "Tokens not loaded yet");
+ AuthToken token = AuthToken.createWithPlainToken(id, t, expirationDate);
+ tokens.put(id, token);
+ return token;
+ }
+
+ public void updateToken(AuthToken token) {
+ checkState(tokens != null, "Tokens not loaded yet");
+ tokens.remove(token.id());
+ tokens.put(token.id(), token);
+ }
+
+ @Nullable
+ public AuthToken getToken(String id) {
+ checkState(tokens != null, "Tokens not loaded yet");
+ return tokens.get(id);
+ }
+
+ public List<AuthToken> getTokens() {
+ checkState(tokens != null, "Tokens not loaded yet");
+ return List.copyOf(tokens.values());
+ }
+
+ @Override
+ protected boolean onSave(CommitBuilder commit) throws IOException {
+ if (Strings.isNullOrEmpty(commit.getMessage())) {
+ commit.setMessage("Updated tokens\n");
+ }
+
+ Config tokenConfig = new Config();
+ for (AuthToken token : tokens.values()) {
+ tokenConfig.setString("token", token.id(), "hash", token.hashedToken());
+ if (token.expirationDate().isPresent()) {
+ tokenConfig.setString(
+ "token", token.id(), "expiration", token.expirationDate().get().toString());
+ }
+ }
+
+ saveUTF8(VersionedAuthTokens.FILE_NAME, tokenConfig.toText());
+ return true;
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 0b7fda8..7f34eb2 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -282,7 +282,8 @@
String oldEmail = extId.email();
if (newEmail != null && !newEmail.equals(oldEmail)) {
ExternalId extIdWithNewEmail =
- externalIdFactory.create(extId.key(), extId.accountId(), newEmail, extId.password());
+ externalIdFactory.createWithPassword(
+ extId.key(), extId.accountId(), newEmail, extId.password());
checkEmailNotUsed(extId.accountId(), extIdWithNewEmail);
accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
diff --git a/java/com/google/gerrit/server/account/AccountModule.java b/java/com/google/gerrit/server/account/AccountModule.java
index c1305cf..30eba72 100644
--- a/java/com/google/gerrit/server/account/AccountModule.java
+++ b/java/com/google/gerrit/server/account/AccountModule.java
@@ -14,11 +14,13 @@
package com.google.gerrit.server.account;
-import com.google.inject.AbstractModule;
+import com.google.gerrit.extensions.config.FactoryModule;
-public class AccountModule extends AbstractModule {
+public class AccountModule extends FactoryModule {
@Override
protected void configure() {
bind(AuthRequest.Factory.class);
+ bind(AuthTokenVerifier.class);
+ factory(PasswordMigrator.Factory.class);
}
}
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index 14b363b..c7bf5dc 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -34,6 +34,8 @@
public static final TypeLiteral<RestView<StarredChange>> STARRED_CHANGE_KIND =
new TypeLiteral<>() {};
+ public static final TypeLiteral<RestView<Token>> TOKEN_KIND = new TypeLiteral<>() {};
+
private final IdentifiedUser user;
public AccountResource(IdentifiedUser user) {
@@ -130,4 +132,17 @@
return labels;
}
}
+
+ public static class Token extends AccountResource {
+ private final String id;
+
+ public Token(IdentifiedUser user, String id) {
+ super(user);
+ this.id = id;
+ }
+
+ public String getId() {
+ return id;
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/account/AuthToken.java b/java/com/google/gerrit/server/account/AuthToken.java
new file mode 100644
index 0000000..390042b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthToken.java
@@ -0,0 +1,71 @@
+// 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;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+@AutoValue
+public abstract class AuthToken {
+ private static final Pattern TOKEN_ID_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9-_]*$");
+
+ public static AuthToken createWithPlainToken(@Nullable String id, String plainToken)
+ throws InvalidAuthTokenException {
+ return createWithPlainToken(id, plainToken, Optional.empty());
+ }
+
+ public static AuthToken createWithPlainToken(
+ @Nullable String id, String plainToken, Optional<Instant> expirationDate)
+ throws InvalidAuthTokenException {
+ return create(id, HashedPassword.fromPassword(plainToken).encode(), expirationDate);
+ }
+
+ public static AuthToken create(@Nullable String id, String hashedToken)
+ throws InvalidAuthTokenException {
+ return create(id, hashedToken, Optional.empty());
+ }
+
+ public static AuthToken create(
+ @Nullable String id, String hashedToken, Optional<Instant> expirationDate)
+ throws InvalidAuthTokenException {
+ if (Strings.isNullOrEmpty(id)) {
+ id = "token_" + System.currentTimeMillis();
+ } else {
+ validateId(id);
+ }
+ return new AutoValue_AuthToken(id, hashedToken, expirationDate);
+ }
+
+ public abstract String id();
+
+ public abstract String hashedToken();
+
+ public abstract Optional<Instant> expirationDate();
+
+ public boolean isExpired() {
+ return expirationDate().isPresent() && Instant.now().isAfter(expirationDate().get());
+ }
+
+ 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.");
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AuthTokenAccessor.java b/java/com/google/gerrit/server/account/AuthTokenAccessor.java
new file mode 100644
index 0000000..5fe5b95
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthTokenAccessor.java
@@ -0,0 +1,48 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public interface AuthTokenAccessor {
+ public List<AuthToken> getTokens(Account.Id accountId);
+
+ public Optional<AuthToken> getToken(Account.Id accountId, String id);
+
+ public AuthToken addPlainToken(
+ Account.Id accountId, String id, String token, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException;
+
+ public AuthToken addToken(
+ Account.Id accountId, String id, String hashedToken, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException;
+
+ public void deleteToken(Account.Id accountId, String id)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException;
+
+ public List<AuthToken> getValidTokens(Account.Id accountId);
+
+ public void deleteAllTokens(Account.Id accountId)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException;
+
+ public void addTokens(Account.Id accountId, Collection<AuthToken> tokens)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException;
+}
diff --git a/java/com/google/gerrit/server/account/AuthTokenCache.java b/java/com/google/gerrit/server/account/AuthTokenCache.java
new file mode 100644
index 0000000..9452a73
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthTokenCache.java
@@ -0,0 +1,86 @@
+// 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;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+
+@Singleton
+public class AuthTokenCache {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private static final String CACHE_NAME = "tokens";
+
+ private final LoadingCache<Account.Id, List<AuthToken>> cache;
+
+ public static Module module() {
+ return new CacheModule() {
+ @Override
+ protected void configure() {
+ cache(CACHE_NAME, Account.Id.class, new TypeLiteral<List<AuthToken>>() {})
+ .loader(Loader.class);
+ bind(AuthTokenCache.class);
+ }
+ };
+ }
+
+ @Inject
+ AuthTokenCache(@Named(CACHE_NAME) LoadingCache<Account.Id, List<AuthToken>> cache) {
+ this.cache = cache;
+ }
+
+ public List<AuthToken> get(Account.Id accountId) {
+ try {
+ return cache.get(accountId);
+ } catch (ExecutionException e) {
+ logger.atWarning().withCause(e).log(
+ "Cannot load authentication tokens for %d", accountId.get());
+ throw new StorageException(e);
+ }
+ }
+
+ public void evict(Account.Id accountId) {
+ if (accountId != null) {
+ logger.atFine().log("Evict authentication token for username %d", accountId.get());
+ cache.invalidate(accountId);
+ }
+ }
+
+ static class Loader extends CacheLoader<Account.Id, List<AuthToken>> {
+ private final DirectAuthTokenAccessor directAccessor;
+
+ @Inject
+ Loader(DirectAuthTokenAccessor directAccessor) {
+ this.directAccessor = directAccessor;
+ }
+
+ @Override
+ public ImmutableList<AuthToken> load(Account.Id accountId) throws Exception {
+ return directAccessor.getTokens(accountId);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AuthTokenConflictException.java b/java/com/google/gerrit/server/account/AuthTokenConflictException.java
new file mode 100644
index 0000000..77af119
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthTokenConflictException.java
@@ -0,0 +1,33 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+
+public class AuthTokenConflictException extends InvalidAuthTokenException {
+ private static final long serialVersionUID = 1L;
+
+ public AuthTokenConflictException(String id, Account.Id accountId) {
+ super(message(id, accountId));
+ }
+
+ public AuthTokenConflictException(String id, Account.Id accountId, Throwable cause) {
+ super(message(id, accountId), cause);
+ }
+
+ private static String message(String id, Account.Id accountId) {
+ return String.format("A token with id %s already exists for account %d.", id, accountId.get());
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java b/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java
new file mode 100644
index 0000000..000d892
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java
@@ -0,0 +1,118 @@
+// 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;
+
+import static com.google.gerrit.server.mail.EmailFactories.AUTH_TOKEN_WILL_EXPIRE;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbImpl;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class AuthTokenExpiryNotifier implements Runnable {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ private static final long FIRST_NOTIFICATION_BEFORE_EXPIRY = 7L; // 7 days
+
+ private final AccountsNoteDbImpl accounts;
+ private final AuthTokenAccessor tokenAccessor;
+ private final EmailFactories emailFactories;
+
+ public static Module module() {
+ return new LifecycleModule() {
+ @Override
+ protected void configure() {
+ bind(AuthTokenExpiryNotifier.class);
+ listener().to(AuthTokenExpiryNotifier.Lifecycle.class);
+ }
+ };
+ }
+
+ static class Lifecycle implements LifecycleListener {
+ private final WorkQueue queue;
+ private final AuthTokenExpiryNotifier notifier;
+ private final Optional<Schedule> schedule;
+
+ @Inject
+ Lifecycle(WorkQueue queue, AuthTokenExpiryNotifier notifier) {
+ this.queue = queue;
+ this.notifier = notifier;
+ schedule = ScheduleConfig.Schedule.create(TimeUnit.DAYS.toMillis(1), "00:00");
+ }
+
+ @Override
+ public void start() {
+ if (schedule.isPresent()) {
+ queue.scheduleAtFixedRate(notifier, schedule.get());
+ }
+ }
+
+ @Override
+ public void stop() {
+ // handled by WorkQueue.stop() already
+ }
+ }
+
+ @Inject
+ public AuthTokenExpiryNotifier(
+ AccountsNoteDbImpl accounts, AuthTokenAccessor tokenAccessor, EmailFactories emailFactories) {
+ this.accounts = accounts;
+ this.tokenAccessor = tokenAccessor;
+ this.emailFactories = emailFactories;
+ }
+
+ @Override
+ public void run() {
+ Instant now = Instant.now();
+ try {
+ for (AccountState account : accounts.all()) {
+ for (AuthToken token : tokenAccessor.getTokens(account.account().id())) {
+ if (token.expirationDate().isEmpty()) {
+ continue;
+ }
+ Instant expirationDate = token.expirationDate().get();
+ if (expirationDate.isBefore(now.plus(FIRST_NOTIFICATION_BEFORE_EXPIRY, ChronoUnit.DAYS))
+ && expirationDate.isAfter(
+ now.plus(FIRST_NOTIFICATION_BEFORE_EXPIRY - 1, ChronoUnit.DAYS))) {
+ logger.atInfo().log(
+ "Token %s for account %s is expiring soon.", token.id(), account.account().id());
+ emailFactories
+ .createOutgoingEmail(
+ AUTH_TOKEN_WILL_EXPIRE,
+ emailFactories.createAuthTokenWillExpireEmail(account.account(), token))
+ .send();
+ }
+ }
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read accounts from NoteDB", e);
+ } catch (EmailException e) {
+ logger.atSevere().withCause(e).log("Failed to send token expiry notification email");
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AuthTokenModule.java b/java/com/google/gerrit/server/account/AuthTokenModule.java
new file mode 100644
index 0000000..9793337
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthTokenModule.java
@@ -0,0 +1,35 @@
+// 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;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+public class AuthTokenModule extends FactoryModule {
+
+ @Override
+ protected void configure() {
+ factory(HttpPasswordFallbackAuthTokenAccessor.Factory.class);
+ }
+
+ @Provides
+ @Singleton
+ public AuthTokenAccessor createAuthTokenAccessor(
+ HttpPasswordFallbackAuthTokenAccessor.Factory fallbackFactory,
+ DirectAuthTokenAccessor directAccessor) {
+ return fallbackFactory.create(directAccessor);
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AuthTokenVerifier.java b/java/com/google/gerrit/server/account/AuthTokenVerifier.java
new file mode 100644
index 0000000..a306c28
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AuthTokenVerifier.java
@@ -0,0 +1,58 @@
+// 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;
+
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.inject.Inject;
+
+/** Checks if a given username and token match a user's credentials. */
+public class AuthTokenVerifier {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final AuthTokenAccessor tokenAccessor;
+
+ @Inject
+ public AuthTokenVerifier(AuthTokenAccessor tokenAccessor) {
+ this.tokenAccessor = tokenAccessor;
+ }
+
+ /**
+ * Checks if a given username and token match a user's credentials.
+ *
+ * @param accountId the account ID to check.
+ * @param providedToken the token to check.
+ * @return whether there is a token hash stored for the account that matches the provided token.
+ */
+ public boolean checkToken(Account.Id accountId, @Nullable String providedToken) {
+ if (Strings.isNullOrEmpty(providedToken)) {
+ return false;
+ }
+
+ try {
+ for (AuthToken t : tokenAccessor.getValidTokens(accountId)) {
+ if (HashedPassword.decode(t.hashedToken()).checkPassword(providedToken)) {
+ return true;
+ }
+ }
+ } catch (HashedPassword.DecoderException e) {
+ logger.atSevere().withCause(e).log(
+ "Could not decode token for account %s: %s ", accountId, e.getMessage());
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/account/CachingAuthTokenAccessor.java b/java/com/google/gerrit/server/account/CachingAuthTokenAccessor.java
new file mode 100644
index 0000000..cc9a789
--- /dev/null
+++ b/java/com/google/gerrit/server/account/CachingAuthTokenAccessor.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.server.account;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Read/write authentication tokens by user ID using a cache for faster access. */
+public class CachingAuthTokenAccessor implements AuthTokenAccessor {
+
+ public interface Factory {
+ CachingAuthTokenAccessor create(AuthTokenAccessor accessor);
+ }
+
+ private final AuthTokenCache authTokenCache;
+ private final AuthTokenAccessor accessor;
+
+ @AssistedInject
+ CachingAuthTokenAccessor(
+ AuthTokenCache authTokenCache, @Assisted AuthTokenAccessor directAuthTokenAccessor) {
+ this.authTokenCache = authTokenCache;
+ this.accessor = directAuthTokenAccessor;
+ }
+
+ @Override
+ public List<AuthToken> getValidTokens(Account.Id accountId) {
+ return getTokens(accountId).stream().filter(t -> !t.isExpired()).toList();
+ }
+
+ @Override
+ public List<AuthToken> getTokens(Account.Id accountId) {
+ return authTokenCache.get(accountId);
+ }
+
+ @Override
+ public Optional<AuthToken> getToken(Account.Id accountId, String id) {
+ return getTokens(accountId).stream().filter(token -> token.id().equals(id)).findFirst();
+ }
+
+ @Override
+ public synchronized void addTokens(Account.Id accountId, Collection<AuthToken> tokens)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ accessor.addTokens(accountId, tokens);
+ authTokenCache.evict(accountId);
+ }
+
+ @Override
+ @CanIgnoreReturnValue
+ public synchronized AuthToken addToken(
+ Account.Id accountId, String id, String hashedToken, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ AuthToken token = accessor.addToken(accountId, id, hashedToken, expiration);
+ authTokenCache.evict(accountId);
+ return token;
+ }
+
+ @Override
+ public AuthToken addPlainToken(
+ Account.Id accountId, String id, String token, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ AuthToken authToken = accessor.addPlainToken(accountId, id, token, expiration);
+ authTokenCache.evict(accountId);
+ return authToken;
+ }
+
+ @Override
+ public void deleteToken(Account.Id accountId, String id)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ accessor.deleteToken(accountId, id);
+ authTokenCache.evict(accountId);
+ }
+
+ @Override
+ public void deleteAllTokens(Account.Id accountId)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ accessor.deleteAllTokens(accountId);
+ authTokenCache.evict(accountId);
+ }
+}
diff --git a/java/com/google/gerrit/server/account/CachingAuthTokenModule.java b/java/com/google/gerrit/server/account/CachingAuthTokenModule.java
new file mode 100644
index 0000000..d993261
--- /dev/null
+++ b/java/com/google/gerrit/server/account/CachingAuthTokenModule.java
@@ -0,0 +1,39 @@
+// 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;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+public class CachingAuthTokenModule extends FactoryModule {
+
+ @Override
+ protected void configure() {
+ install(AuthTokenCache.module());
+ install(AuthTokenExpiryNotifier.module());
+ factory(CachingAuthTokenAccessor.Factory.class);
+ factory(HttpPasswordFallbackAuthTokenAccessor.Factory.class);
+ }
+
+ @Provides
+ @Singleton
+ public AuthTokenAccessor createAuthTokenAccessor(
+ HttpPasswordFallbackAuthTokenAccessor.Factory fallbackFactory,
+ CachingAuthTokenAccessor.Factory cachingFactory,
+ DirectAuthTokenAccessor directAccessor) {
+ return fallbackFactory.create(cachingFactory.create(directAccessor));
+ }
+}
diff --git a/java/com/google/gerrit/server/account/DirectAuthTokenAccessor.java b/java/com/google/gerrit/server/account/DirectAuthTokenAccessor.java
new file mode 100644
index 0000000..49a2a78
--- /dev/null
+++ b/java/com/google/gerrit/server/account/DirectAuthTokenAccessor.java
@@ -0,0 +1,145 @@
+// 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;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Read/write authentication tokens by user ID. */
+@Singleton
+public class DirectAuthTokenAccessor implements AuthTokenAccessor {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ public static final String LEGACY_ID = "legacy";
+
+ private final AllUsersName allUsersName;
+ private final VersionedAuthTokens.Factory authTokenFactory;
+ private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+ private final IdentifiedUser.GenericFactory userFactory;
+
+ @Inject
+ DirectAuthTokenAccessor(
+ AllUsersName allUsersName,
+ VersionedAuthTokens.Factory authTokenFactory,
+ Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+ IdentifiedUser.GenericFactory userFactory) {
+ this.allUsersName = allUsersName;
+ this.authTokenFactory = authTokenFactory;
+ this.metaDataUpdateFactory = metaDataUpdateFactory;
+ this.userFactory = userFactory;
+ }
+
+ @Override
+ public ImmutableList<AuthToken> getValidTokens(Account.Id accountId) {
+ return ImmutableList.copyOf(getTokens(accountId).stream().filter(t -> !t.isExpired()).toList());
+ }
+
+ @Override
+ public ImmutableList<AuthToken> getTokens(Account.Id accountId) {
+ try {
+ return readFromNoteDb(accountId).getTokens();
+ } catch (IOException | ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log("Error reading auth tokens for account %s", accountId);
+ throw new StorageException(e);
+ }
+ }
+
+ @Override
+ public Optional<AuthToken> getToken(Account.Id accountId, String id) {
+ try {
+ return Optional.ofNullable(readFromNoteDb(accountId).getToken(id));
+ } catch (IOException | ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log("Error reading auth tokens for account %s", accountId);
+ throw new StorageException(e);
+ }
+ }
+
+ @Override
+ @CanIgnoreReturnValue
+ public synchronized AuthToken addPlainToken(
+ Account.Id accountId, String id, String token, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ String hashedToken = HashedPassword.fromPassword(token).encode();
+ return addToken(accountId, id, hashedToken, expiration);
+ }
+
+ @Override
+ public synchronized void addTokens(Account.Id accountId, Collection<AuthToken> tokens)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ VersionedAuthTokens authTokens = readFromNoteDb(accountId);
+ for (AuthToken token : tokens) {
+ authTokens.addToken(token);
+ }
+ commit(accountId, authTokens);
+ }
+
+ @Override
+ @CanIgnoreReturnValue
+ public synchronized AuthToken addToken(
+ Account.Id accountId, String id, String hashedToken, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ VersionedAuthTokens authTokens = readFromNoteDb(accountId);
+ AuthToken token = authTokens.addToken(id, hashedToken, expiration);
+ commit(accountId, authTokens);
+ return token;
+ }
+
+ @Override
+ public synchronized void deleteToken(Account.Id accountId, String id)
+ throws IOException, ConfigInvalidException {
+ VersionedAuthTokens authTokens = readFromNoteDb(accountId);
+ if (authTokens.deleteToken(id)) {
+ commit(accountId, authTokens);
+ }
+ }
+
+ @Override
+ public void deleteAllTokens(Account.Id accountId) throws IOException, ConfigInvalidException {
+ VersionedAuthTokens authTokens = readFromNoteDb(accountId);
+ if (authTokens.getTokens().isEmpty()) {
+ return;
+ }
+ for (AuthToken token : getTokens(accountId)) {
+ @SuppressWarnings("unused")
+ var unused = authTokens.deleteToken(token.id());
+ }
+ commit(accountId, authTokens);
+ }
+
+ private VersionedAuthTokens readFromNoteDb(Account.Id accountId)
+ throws IOException, ConfigInvalidException {
+ return authTokenFactory.create(accountId).load();
+ }
+
+ private void commit(Account.Id accountId, VersionedAuthTokens authTokens) throws IOException {
+ try (MetaDataUpdate md =
+ metaDataUpdateFactory.get().create(allUsersName, userFactory.create(accountId))) {
+ authTokens.commit(md, false);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/account/HttpPasswordFallbackAuthTokenAccessor.java b/java/com/google/gerrit/server/account/HttpPasswordFallbackAuthTokenAccessor.java
new file mode 100644
index 0000000..baf945a
--- /dev/null
+++ b/java/com/google/gerrit/server/account/HttpPasswordFallbackAuthTokenAccessor.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.account;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class HttpPasswordFallbackAuthTokenAccessor implements AuthTokenAccessor {
+
+ public static final String LEGACY_ID = "legacy";
+
+ public interface Factory {
+ HttpPasswordFallbackAuthTokenAccessor create(AuthTokenAccessor accessor);
+ }
+
+ private final AccountCache accountCache;
+ private final AuthTokenAccessor accessor;
+
+ @AssistedInject
+ HttpPasswordFallbackAuthTokenAccessor(
+ AccountCache accountCache, @Assisted AuthTokenAccessor accessor) {
+ this.accessor = accessor;
+ this.accountCache = accountCache;
+ }
+
+ @Override
+ public List<AuthToken> getTokens(Account.Id accountId) {
+ List<AuthToken> tokens = accessor.getTokens(accountId);
+ if (tokens.isEmpty()) {
+ tokens = fallBackToLegacyHttpPassword(accountId);
+ }
+ return tokens;
+ }
+
+ @Override
+ public List<AuthToken> getValidTokens(Account.Id accountId) {
+ return ImmutableList.copyOf(getTokens(accountId).stream().filter(t -> !t.isExpired()).toList());
+ }
+
+ @Override
+ public Optional<AuthToken> getToken(Account.Id accountId, String id) {
+ return getTokens(accountId).stream().filter(token -> token.id().equals(id)).findFirst();
+ }
+
+ @Override
+ public AuthToken addToken(
+ Account.Id accountId, String id, String hashedToken, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ return accessor.addToken(accountId, id, hashedToken, expiration);
+ }
+
+ @Override
+ public void addTokens(Account.Id accountId, Collection<AuthToken> tokens)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ accessor.addTokens(accountId, tokens);
+ }
+
+ @Override
+ public AuthToken addPlainToken(
+ Account.Id accountId, String id, String token, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ return accessor.addPlainToken(accountId, id, token, expiration);
+ }
+
+ @Override
+ public void deleteToken(Account.Id accountId, String id)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ accessor.deleteToken(accountId, id);
+ }
+
+ @Override
+ public void deleteAllTokens(Account.Id accountId)
+ throws IOException, ConfigInvalidException, InvalidAuthTokenException {
+ accessor.deleteAllTokens(accountId);
+ }
+
+ ImmutableList<AuthToken> fallBackToLegacyHttpPassword(Account.Id accountId) {
+ AccountState accountState = accountCache.getEvenIfMissing(accountId);
+ Optional<ExternalId> optUser =
+ accountState.externalIds().stream()
+ .filter(e -> e.key().scheme().equals(SCHEME_USERNAME))
+ .findFirst();
+ if (optUser.isEmpty()) {
+ return ImmutableList.of();
+ }
+ ExternalId user = optUser.get();
+ String password = user.password();
+ if (password != null) {
+ try {
+ return ImmutableList.of(AuthToken.create(LEGACY_ID, password));
+ } catch (InvalidAuthTokenException e1) {
+ // Can be ignored because the token ID is hardcoded.
+ }
+ }
+ return ImmutableList.of();
+ }
+}
diff --git a/java/com/google/gerrit/server/account/InvalidAuthTokenException.java b/java/com/google/gerrit/server/account/InvalidAuthTokenException.java
new file mode 100644
index 0000000..8666db6
--- /dev/null
+++ b/java/com/google/gerrit/server/account/InvalidAuthTokenException.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;
+
+public class InvalidAuthTokenException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidAuthTokenException(String message) {
+ super(message);
+ }
+
+ public InvalidAuthTokenException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/java/com/google/gerrit/server/account/PasswordMigrator.java b/java/com/google/gerrit/server/account/PasswordMigrator.java
new file mode 100644
index 0000000..c949e3f
--- /dev/null
+++ b/java/com/google/gerrit/server/account/PasswordMigrator.java
@@ -0,0 +1,235 @@
+// 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;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.git.MultiProgressMonitor.TaskKind;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.project.LockManager;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+public class PasswordMigrator implements Runnable {
+ static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ @VisibleForTesting public static final String DEFAULT_ID = "default";
+
+ private final GitRepositoryManager repoManager;
+ private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
+ private final AuthTokenAccessor tokenAccessor;
+ private final ExternalIds externalIds;
+ private final ExternalIdFactory externalIdFactory;
+ private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+ private final AllUsersName allUsers;
+ private final ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+ private final Optional<Instant> expirationDate;
+ private final LockManager lockManager;
+
+ private MultiProgressMonitor mpm;
+ private Task doneTask;
+ private Task failedTask;
+
+ public interface Factory {
+ public PasswordMigrator create(Optional<Instant> expiryDate);
+ }
+
+ @AssistedInject
+ public PasswordMigrator(
+ GitRepositoryManager repoManager,
+ MultiProgressMonitor.Factory multiProgressMonitorFactory,
+ AuthTokenAccessor tokenAccessor,
+ ExternalIds externalIds,
+ ExternalIdFactory externalIdFactory,
+ AllUsersName allUsers,
+ ExternalIdNotes.FactoryNoReindex externalIdNotesFactory,
+ Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
+ @Assisted Optional<Instant> expirationDate,
+ LockManager lockManager) {
+ this.repoManager = repoManager;
+ this.multiProgressMonitorFactory = multiProgressMonitorFactory;
+ this.tokenAccessor = tokenAccessor;
+ this.externalIds = externalIds;
+ this.externalIdFactory = externalIdFactory;
+ this.allUsers = allUsers;
+ this.externalIdNotesFactory = externalIdNotesFactory;
+ this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
+ this.expirationDate = expirationDate;
+ this.lockManager = lockManager;
+ }
+
+ @Override
+ public void run() {
+ Lock lock = lockManager.getLock("MigratePasswordsToTokens");
+ if (!lock.tryLock()) {
+ logger.atWarning().log("Migration of passwords to tokens already running.");
+ return;
+ }
+ try {
+ ImmutableSet<ExternalId> todo;
+ try {
+ todo = getAllUsernameExternalIds();
+ } catch (IOException | ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log("Unable to read external IDs.");
+ return;
+ }
+
+ if (todo.isEmpty()) {
+ logger.atInfo().log("No accounts with HTTP passwords exist. Nothing to do.");
+ return;
+ }
+
+ Stopwatch sw = Stopwatch.createStarted();
+ mpm =
+ multiProgressMonitorFactory.create(
+ NullOutputStream.INSTANCE, TaskKind.MIGRATION, "Migrating HTTP passwords", true);
+ doneTask = mpm.beginSubTask("passwords", todo.size());
+ failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+ List<ExternalId> failedToMigrate = new ArrayList<>();
+ for (ExternalId extId : todo) {
+ if (!createToken(extId, expirationDate)) {
+ failedToMigrate.add(extId);
+ }
+ logger.atInfo().atMostEvery(30, TimeUnit.SECONDS).log(
+ "Migrated %d/%d HTTP passwords (%d failed).",
+ doneTask.getCount(), todo.size(), failedTask.getCount());
+ }
+ doneTask.end();
+ failedTask.end();
+ mpm.end();
+ logger.atInfo().log(
+ "Finished creating tokens. %d/%d finished successfully in %d min. %d tasks" + " failed.",
+ doneTask.getCount(), todo.size(), sw.elapsed(TimeUnit.MINUTES), failedTask.getCount());
+ logger.atInfo().log("Starting to delete HTTP passwords from External IDs.");
+ try {
+ deletePasswordsInExternalId(failedToMigrate);
+ } catch (ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log(
+ "Migration of HTTP passwords failed. Unable to load External IDs.");
+ }
+ sw.stop();
+ logger.atInfo().log(
+ "Finished deleting HTTP passwords from External IDs. Total migration to tokens took %d"
+ + " min.",
+ sw.elapsed(TimeUnit.MINUTES));
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private boolean createToken(ExternalId extId, Optional<Instant> expirationDate) {
+ String hashedPassword = extId.password();
+ if (hashedPassword == null) {
+ return true;
+ }
+ Account.Id accountId = extId.accountId();
+ try (TraceTimer traceTimer =
+ TraceContext.newTimer(
+ "Creating token from HTTP password",
+ Metadata.builder().projectName("All-Users").accountId(accountId.get()).build())) {
+ if (tokenAccessor.getToken(accountId, PasswordMigrator.DEFAULT_ID).isPresent()) {
+ logger.atFine().log("HTTP password of account %d was already migrated.", accountId.get());
+ } else {
+ try {
+ @SuppressWarnings("unused")
+ var unused =
+ tokenAccessor.addToken(
+ accountId, PasswordMigrator.DEFAULT_ID, hashedPassword, expirationDate);
+ } catch (IOException | ConfigInvalidException | InvalidAuthTokenException e) {
+ logger.atSevere().withCause(e).log(
+ "Failed to migrate HTTP password to token for account %d", accountId.get());
+ failedTask.update(1);
+ return false;
+ }
+ }
+ doneTask.update(1);
+ }
+ return true;
+ }
+
+ private void deletePasswordsInExternalId(List<ExternalId> exclude) throws ConfigInvalidException {
+
+ try (TraceTimer traceTimer =
+ TraceContext.newTimer(
+ "Deleting Passwords from external IDs",
+ Metadata.builder()
+ .projectName("All-Users")
+ .noteDbRefName("refs/meta/external-ids")
+ .build())) {
+ ImmutableSet<ExternalId> extIdsWithPassword;
+ try {
+ extIdsWithPassword = getAllUsernameExternalIds();
+ } catch (IOException | ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log(
+ "Unable to read external IDs. Can't delete passwords from external IDs.");
+ return;
+ }
+ Set<ExternalId> updatedExtIds =
+ extIdsWithPassword.stream()
+ .filter(e -> !exclude.contains(e))
+ .map(e -> externalIdFactory.createWithEmail(e.key(), e.accountId(), e.email()))
+ .collect(Collectors.toSet());
+ try (Repository repo = repoManager.openRepository(allUsers)) {
+ ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+ extIdNotes.replaceByKeys(
+ extIdsWithPassword.stream().map(e -> e.key()).collect(Collectors.toSet()),
+ updatedExtIds);
+ try (MetaDataUpdate metaDataUpdate = metaDataUpdateServerFactory.get().create(allUsers)) {
+ metaDataUpdate.setMessage("Migrate HTTP passwords to tokens");
+ extIdNotes.commit(metaDataUpdate);
+ }
+ } catch (IOException | DuplicateExternalIdKeyException e) {
+ logger.atSevere().withCause(e).log("Unable to replace External IDs.");
+ }
+ }
+ }
+
+ private ImmutableSet<ExternalId> getAllUsernameExternalIds()
+ throws IOException, ConfigInvalidException {
+ return ImmutableSet.copyOf(
+ externalIds.all().stream()
+ .filter(e -> e.key().scheme().equals(SCHEME_USERNAME) && e.password() != null)
+ .collect(Collectors.toSet()));
+ }
+}
diff --git a/java/com/google/gerrit/server/account/VersionedAuthTokens.java b/java/com/google/gerrit/server/account/VersionedAuthTokens.java
new file mode 100644
index 0000000..dc056b3
--- /dev/null
+++ b/java/com/google/gerrit/server/account/VersionedAuthTokens.java
@@ -0,0 +1,227 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * 'tokens.config' file in the refs/users/CD/ABCD branches of the All-Users repository.
+ *
+ * <p>The `tokens.config' file stores the authentication tokens of the user. The file uses the git
+ * config format, where each token is a subsection.
+ */
+public class VersionedAuthTokens extends VersionedMetaData {
+
+ public interface Factory {
+ VersionedAuthTokens create(Account.Id accountId);
+ }
+
+ public static final String FILE_NAME = "tokens.config";
+
+ private final GitRepositoryManager repoManager;
+ private final AllUsersName allUsersName;
+ private final Optional<Duration> maxAuthTokenLifetime;
+ private final int maxTokens;
+
+ private final Account.Id accountId;
+ private final String ref;
+ private Map<String, AuthToken> tokens;
+
+ @Inject
+ public VersionedAuthTokens(
+ GitRepositoryManager repoManager,
+ AllUsersName allUsersName,
+ AuthConfig authConfig,
+ @Assisted Account.Id accountId) {
+ this.repoManager = repoManager;
+ this.allUsersName = allUsersName;
+ this.maxAuthTokenLifetime = authConfig.getMaxAuthTokenLifetime();
+ this.maxTokens = authConfig.getMaxAuthTokensPerAccount();
+
+ this.accountId = accountId;
+ this.ref = RefNames.refsUsers(accountId);
+ }
+
+ @Override
+ protected String getRefName() {
+ return ref;
+ }
+
+ public VersionedAuthTokens load() throws IOException, ConfigInvalidException {
+ try (Repository git = repoManager.openRepository(allUsersName)) {
+ load(allUsersName, git);
+ }
+ return this;
+ }
+
+ @Override
+ protected void onLoad() throws IOException, ConfigInvalidException {
+ tokens = parse(readUTF8(FILE_NAME));
+ }
+
+ @Override
+ protected boolean onSave(CommitBuilder commit) throws IOException {
+ if (Strings.isNullOrEmpty(commit.getMessage())) {
+ commit.setMessage("Updated authentication tokens\n");
+ }
+
+ Config tokenConfig = new Config();
+ for (AuthToken token : tokens.values()) {
+ tokenConfig.setString("token", token.id(), "hash", token.hashedToken());
+ if (token.expirationDate().isPresent()) {
+ tokenConfig.setString(
+ "token", token.id(), "expiration", token.expirationDate().get().toString());
+ }
+ }
+
+ saveUTF8(FILE_NAME, tokenConfig.toText());
+ return true;
+ }
+
+ public static Map<String, AuthToken> parse(String s) throws ConfigInvalidException {
+ Config tokenConfig = new Config();
+ tokenConfig.fromText(s);
+ Map<String, AuthToken> tokens = new HashMap<>(tokenConfig.getSubsections("token").size());
+ for (String id : tokenConfig.getSubsections("token")) {
+ String expiration = tokenConfig.getString("token", id, "expiration");
+ Optional<Instant> expirationInstant =
+ expiration != null ? Optional.of(Instant.parse(expiration)) : Optional.empty();
+ try {
+ tokens.put(
+ id,
+ AuthToken.create(id, tokenConfig.getString("token", id, "hash"), expirationInstant));
+ } catch (InvalidAuthTokenException e) {
+ // Tokens were validated on creation.
+ }
+ }
+ return tokens;
+ }
+
+ /** Returns all authentication tokens. */
+ ImmutableList<AuthToken> getTokens() {
+ checkLoaded();
+ return ImmutableList.copyOf(tokens.values());
+ }
+
+ /**
+ * Returns the token with the given id.
+ *
+ * @param id id / name of the token
+ * @return the token, <code>null</code> if there is no token with this id
+ */
+ @Nullable
+ AuthToken getToken(String id) {
+ checkLoaded();
+ return tokens.get(id);
+ }
+
+ /**
+ * Adds a new token.
+ *
+ * @param id the id of the token
+ * @param hashedToken the hashed token to be added
+ * @param expiration the expiration instant of the token
+ * @return the new Token
+ * @throws InvalidAuthTokenException if the token or its ID is invalid
+ */
+ AuthToken addToken(String id, String hashedToken, Optional<Instant> expiration)
+ throws InvalidAuthTokenException {
+ checkLoaded();
+
+ AuthToken token = AuthToken.create(id, hashedToken, expiration);
+ return addToken(token);
+ }
+
+ /**
+ * Adds a new token.
+ *
+ * @param token the token to be added
+ * @return the new Token
+ * @throws InvalidAuthTokenException if the token is invalid, e.g. if the ID already exists or the
+ * lifetime does not comply with the server's configuration.
+ */
+ @CanIgnoreReturnValue
+ AuthToken addToken(AuthToken token) throws InvalidAuthTokenException {
+ checkLoaded();
+
+ if (tokens.size() >= maxTokens) {
+ throw new InvalidAuthTokenException(
+ String.format("Maximum number of tokens (%d) already reached.", maxTokens));
+ }
+
+ if (tokens.containsKey(token.id())) {
+ throw new AuthTokenConflictException(token.id(), accountId);
+ }
+
+ if (maxAuthTokenLifetime.isPresent()) {
+ if (token.expirationDate().isEmpty()) {
+ throw new InvalidAuthTokenException("Tokens with unlimited lifetime are not permitted.");
+ } else if (token
+ .expirationDate()
+ .get()
+ .isAfter(Instant.now().plus(maxAuthTokenLifetime.get()))) {
+ throw new InvalidAuthTokenException(
+ String.format(
+ "Lifetime of token exceeds maximum allowed lifetime of %s days %s hours %s"
+ + " minutes.",
+ maxAuthTokenLifetime.get().toDays(),
+ maxAuthTokenLifetime.get().toHoursPart(),
+ maxAuthTokenLifetime.get().toMinutesPart()));
+ }
+ }
+
+ tokens.put(token.id(), token);
+ return token;
+ }
+
+ /**
+ * Deletes the token with the given id.
+ *
+ * @param id the id
+ * @return <code>true</code> if a token with this id was found and deleted, <code>false
+ * </code> if no token with the given id exists
+ */
+ boolean deleteToken(String id) {
+ checkLoaded();
+ return tokens.remove(id) != null;
+ }
+
+ private void checkLoaded() {
+ checkState(tokens != null, "Tokens not loaded yet");
+ }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 52d5190..cb87853 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -250,6 +250,7 @@
}
}
+ @Deprecated
public static ExternalId create(
Key key,
Account.Id accountId,
@@ -265,6 +266,12 @@
blobId);
}
+ public static ExternalId create(
+ Key key, Account.Id accountId, @Nullable String email, @Nullable ObjectId blobId) {
+ return new AutoValue_ExternalId(
+ key, accountId, key.isCaseInsensitive(), Strings.emptyToNull(email), null, blobId);
+ }
+
public abstract Key key();
public abstract Account.Id accountId();
@@ -273,6 +280,7 @@
public abstract @Nullable String email();
+ @Deprecated
public abstract @Nullable String password();
/**
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index 00a7f6c..6fd68e8 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -40,6 +40,7 @@
* @param hashedPassword the hashed password of the external ID, may be {@code null}
* @return the created external ID
*/
+ @Deprecated
ExternalId create(
String scheme,
String id,
@@ -57,21 +58,6 @@
ExternalId create(ExternalId.Key key, Account.Id accountId);
/**
- * Creates an external ID.
- *
- * @param key the external Id key
- * @param accountId the ID of the account to which the external ID belongs
- * @param email the email of the external ID, may be {@code null}
- * @param hashedPassword the hashed password of the external ID, may be {@code null}
- * @return the created external ID
- */
- ExternalId create(
- ExternalId.Key key,
- Account.Id accountId,
- @Nullable String email,
- @Nullable String hashedPassword);
-
- /**
* Creates an external ID adding a hashed password computed from a plain password.
*
* @param key the external Id key
@@ -80,6 +66,7 @@
* @param plainPassword the plain HTTP password, may be {@code null}
* @return the created external ID
*/
+ @Deprecated
ExternalId createWithPassword(
ExternalId.Key key,
Account.Id accountId,
@@ -94,9 +81,19 @@
* @param plainPassword the plain HTTP password, may be {@code null}
* @return the created external ID
*/
+ @Deprecated
ExternalId createUsername(String id, Account.Id accountId, @Nullable String plainPassword);
/**
+ * Create a external ID for a username (scheme "username").
+ *
+ * @param id the external ID, must not contain colons (':')
+ * @param accountId the ID of the account to which the external ID belongs
+ * @return the created external ID
+ */
+ ExternalId createUsername(String id, Account.Id accountId);
+
+ /**
* Creates an external ID with an email.
*
* @param scheme the scheme name, must not contain colons (':'). E.g. {@link
@@ -129,5 +126,6 @@
ExternalId createEmail(Account.Id accountId, String email);
/** Whether this {@link ExternalIdFactory} supports passwords. */
+ @Deprecated
boolean arePasswordsAllowed();
}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index 2d3e241..3e71b33 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -20,6 +20,5 @@
@Override
protected void configure() {
bind(ExternalIdKeyFactory.class);
- bind(PasswordVerifier.class);
}
}
diff --git a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
deleted file mode 100644
index eb2bea9..0000000
--- a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.inject.Inject;
-import java.util.Collection;
-
-/** Checks if a given username and password match a user's external IDs. */
-public class PasswordVerifier {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
- private final ExternalIdKeyFactory externalIdKeyFactory;
-
- private AuthConfig authConfig;
-
- @Inject
- public PasswordVerifier(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
- this.externalIdKeyFactory = externalIdKeyFactory;
- this.authConfig = authConfig;
- }
-
- /** Returns {@code true} if there is an external ID matching both the username and password. */
- public boolean checkPassword(
- Collection<ExternalId> externalIds, String username, @Nullable String password) {
- if (password == null) {
- return false;
- }
-
- for (ExternalId id : externalIds) {
- // Only process the "username:$USER" entry, which is unique.
- if (!id.isScheme(SCHEME_USERNAME)) {
- continue;
- }
-
- if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username))) {
- if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
- continue;
- }
-
- if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username, false))) {
- continue;
- }
- }
-
- String hashedStr = id.password();
- if (!Strings.isNullOrEmpty(hashedStr)) {
- try {
- return HashedPassword.decode(hashedStr).checkPassword(password);
- } catch (HashedPassword.DecoderException e) {
- logger.atSevere().log("DecoderException for user %s: %s ", username, e.getMessage());
- return false;
- }
- }
- }
- return false;
- }
-}
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
index d3de715..aa321e7 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
@@ -20,6 +20,7 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.InlineMe;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.HashedPassword;
@@ -48,33 +49,24 @@
}
@Override
+ public ExternalId create(ExternalId.Key key, Account.Id accountId) {
+ return create(key, accountId, null, null);
+ }
+
+ @Override
public ExternalId create(String scheme, String id, Account.Id accountId) {
return create(externalIdKeyFactory.create(scheme, id), accountId, null, null);
}
@Override
+ @Deprecated
public ExternalId create(
String scheme,
String id,
Account.Id accountId,
@Nullable String email,
@Nullable String hashedPassword) {
- return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword);
- }
-
- @Override
- public ExternalId create(ExternalId.Key key, Account.Id accountId) {
- return create(key, accountId, null, null);
- }
-
- @Override
- public ExternalId create(
- ExternalId.Key key,
- Account.Id accountId,
- @Nullable String email,
- @Nullable String hashedPassword) {
- return create(
- key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
+ return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword, null);
}
ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
@@ -92,6 +84,7 @@
* {@code null} if the external ID was created in code and is not yet stored in Git.
* @return the created external ID
*/
+ @Deprecated
public ExternalId create(
ExternalId.Key key,
Account.Id accountId,
@@ -102,7 +95,23 @@
key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
}
+ /**
+ * Creates an external ID.
+ *
+ * @param key the external Id key
+ * @param accountId the ID of the account to which the external ID belongs
+ * @param email the email of the external ID, may be {@code null}
+ * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
+ * {@code null} if the external ID was created in code and is not yet stored in Git.
+ * @return the created external ID
+ */
+ public ExternalId create(
+ ExternalId.Key key, Account.Id accountId, @Nullable String email, @Nullable ObjectId blobId) {
+ return ExternalId.create(key, accountId, Strings.emptyToNull(email), blobId);
+ }
+
@Override
+ @Deprecated
public ExternalId createWithPassword(
ExternalId.Key key,
Account.Id accountId,
@@ -111,10 +120,11 @@
plainPassword = Strings.emptyToNull(plainPassword);
String hashedPassword =
plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
- return create(key, accountId, email, hashedPassword);
+ return create(key, accountId, email, hashedPassword, null);
}
@Override
+ @Deprecated
public ExternalId createUsername(
String id, Account.Id accountId, @Nullable String plainPassword) {
return createWithPassword(
@@ -125,6 +135,11 @@
}
@Override
+ public ExternalId createUsername(String id, Account.Id accountId) {
+ return create(externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, id), accountId);
+ }
+
+ @Override
public ExternalId createWithEmail(
String scheme, String id, Account.Id accountId, @Nullable String email) {
return createWithEmail(externalIdKeyFactory.create(scheme, id), accountId, email);
@@ -224,7 +239,9 @@
}
@Override
- public boolean arePasswordsAllowed() {
+ @Deprecated
+ @InlineMe(replacement = "true")
+ public final boolean arePasswordsAllowed() {
return true;
}
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
index eea78e5..f989b74 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -49,8 +49,7 @@
ident,
(ins, noteMap) -> {
ExternalId extId =
- ExternalId.create(
- ExternalId.Key.parse(externalId, false), accountId, null, null, null);
+ ExternalId.create(ExternalId.Key.parse(externalId, false), accountId, null, null);
ObjectId noteId = extId.key().sha1();
Config c = new Config();
extId.writeToConfig(c);
@@ -72,8 +71,7 @@
ident,
(ins, noteMap) -> {
ExternalId extId =
- ExternalId.create(
- ExternalId.Key.parse(externalId, false), accountId, null, null, null);
+ ExternalId.create(ExternalId.Key.parse(externalId, false), accountId, null, null);
ObjectId noteId = ExternalId.Key.parse(externalId + "x", false).sha1();
Config c = new Config();
extId.writeToConfig(c);
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 5defc26..e5cc45e 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
import static javax.servlet.http.HttpServletResponse.SC_OK;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.extensions.api.accounts.AccountApi;
@@ -29,6 +30,8 @@
import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
import com.google.gerrit.extensions.api.accounts.SshKeyInput;
import com.google.gerrit.extensions.api.accounts.StatusInput;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.EditPreferencesInfo;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -52,9 +55,12 @@
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.GpgApiAdapter;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.restapi.account.AddSshKey;
import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.CreateToken;
import com.google.gerrit.server.restapi.account.DeleteAccount;
import com.google.gerrit.server.restapi.account.DeleteActive;
import com.google.gerrit.server.restapi.account.DeleteDraftComments;
@@ -74,6 +80,7 @@
import com.google.gerrit.server.restapi.account.GetPreferences;
import com.google.gerrit.server.restapi.account.GetSshKeys;
import com.google.gerrit.server.restapi.account.GetState;
+import com.google.gerrit.server.restapi.account.GetTokens;
import com.google.gerrit.server.restapi.account.GetWatchedProjects;
import com.google.gerrit.server.restapi.account.Index;
import com.google.gerrit.server.restapi.account.PostWatchedProjects;
@@ -91,8 +98,10 @@
import com.google.gerrit.server.restapi.change.ChangesCollection;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
import java.util.List;
import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
public class AccountApiImpl implements AccountApi {
interface Factory {
@@ -138,6 +147,8 @@
private final GetGroups getGroups;
private final EmailApiImpl.Factory emailApi;
private final PutName putName;
+ private final GetTokens getTokens;
+ private final CreateToken createToken;
private final PutHttpPassword putHttpPassword;
private final DeleteAccount deleteAccount;
@@ -181,6 +192,8 @@
GetGroups getGroups,
EmailApiImpl.Factory emailApi,
PutName putName,
+ CreateToken createToken,
+ GetTokens getTokens,
PutHttpPassword putPassword,
DeleteAccount deleteAccount,
@Assisted AccountResource account) {
@@ -223,6 +236,8 @@
this.getGroups = getGroups;
this.emailApi = emailApi;
this.putName = putName;
+ this.createToken = createToken;
+ this.getTokens = getTokens;
this.putHttpPassword = putPassword;
this.deleteAccount = deleteAccount;
}
@@ -612,8 +627,28 @@
}
}
+ @Override
+ @CanIgnoreReturnValue
+ public AuthTokenInfo createToken(AuthTokenInput input) throws RestApiException {
+ try {
+ return createToken.apply(account, IdString.fromDecoded(input.id), input).value();
+ } catch (InvalidAuthTokenException
+ | IOException
+ | ConfigInvalidException
+ | PermissionBackendException
+ | RestApiException e) {
+ throw asRestApiException("Cannot create token", e);
+ }
+ }
+
+ @Override
+ public List<AuthTokenInfo> getTokens() throws RestApiException {
+ return getTokens.apply(account.getUser());
+ }
+
@Nullable
@Override
+ @Deprecated
public String generateHttpPassword() throws RestApiException {
HttpPasswordInput input = new HttpPasswordInput();
input.generate = true;
@@ -629,6 +664,7 @@
@Nullable
@Override
+ @Deprecated
public String setHttpPassword(String password) throws RestApiException {
HttpPasswordInput input = new HttpPasswordInput();
input.generate = false;
diff --git a/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
index 0618750..6e2d7ef 100644
--- a/java/com/google/gerrit/server/auth/InternalAuthBackend.java
+++ b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
@@ -16,7 +16,7 @@
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.externalids.PasswordVerifier;
+import com.google.gerrit.server.account.AuthTokenVerifier;
import com.google.gerrit.server.config.AuthConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -26,14 +26,14 @@
public class InternalAuthBackend implements AuthBackend {
private final AccountCache accountCache;
private final AuthConfig authConfig;
- private final PasswordVerifier passwordVerifier;
+ private final AuthTokenVerifier tokenVerifier;
@Inject
InternalAuthBackend(
- AccountCache accountCache, AuthConfig authConfig, PasswordVerifier passwordVerifier) {
+ AccountCache accountCache, AuthConfig authConfig, AuthTokenVerifier tokenVerifier) {
this.accountCache = accountCache;
this.authConfig = authConfig;
- this.passwordVerifier = passwordVerifier;
+ this.tokenVerifier = tokenVerifier;
}
@Override
@@ -69,7 +69,7 @@
+ ": account inactive or not provisioned in Gerrit");
}
- if (!passwordVerifier.checkPassword(who.externalIds(), username, req.getPassword().get())) {
+ if (!tokenVerifier.checkToken(who.account().id(), req.getPassword().get())) {
throw new InvalidCredentialsException();
}
return new AuthUser(AuthUser.UUID.create(username), username);
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 28337e2..d87ff52 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -45,6 +45,7 @@
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.git.GroupCollector;
import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtil.MergeBase;
import com.google.gerrit.server.git.MergeUtilFactory;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -579,6 +580,8 @@
ctx.getRevWalk(),
ctx.getInserter(),
dc,
+ "BASE",
+ MergeBase.create(parentCommit),
"PATCH SET",
original,
"BASE",
@@ -620,7 +623,7 @@
}
ObjectId objectId = ctx.getInserter().insert(cb);
CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
- commit.setConflicts(original, base, filesWithGitConflicts);
+ commit.setConflicts(parentCommit, original, base, strategy, filesWithGitConflicts);
logger.atFine().log("rebased commit=%s", commit.name());
return commit;
}
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index d6cbeda..7f6e35c 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -370,8 +370,11 @@
conflicts -> {
ConflictsInfo conflictsInfo = new ConflictsInfo();
conflictsInfo.containsConflicts = conflicts.containsConflicts();
+ conflictsInfo.base = conflicts.base().map(ObjectId::getName).orElse(null);
conflictsInfo.ours = conflicts.ours().map(ObjectId::getName).orElse(null);
conflictsInfo.theirs = conflicts.theirs().map(ObjectId::getName).orElse(null);
+ conflictsInfo.mergeStrategy = conflicts.mergeStrategy().orElse(null);
+ conflictsInfo.noBaseReason = conflicts.noBaseReason().orElse(null);
return conflictsInfo;
})
.orElse(null);
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index e4008fa..36e5a86 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -26,11 +26,13 @@
import com.google.gerrit.server.mail.XsrfException;
import com.google.inject.Inject;
import com.google.inject.Singleton;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.lib.Config;
@@ -68,6 +70,8 @@
private final boolean userNameCaseInsensitiveMigrationMode;
private final int externalIdsRefExpirySecs;
private GitBasicAuthPolicy gitBasicAuthPolicy;
+ private final Duration maxAuthTokenLifetime;
+ private final int maxAuthTokensPerAccount;
@Inject
AuthConfig(@GerritServerConfig Config cfg) throws XsrfException {
@@ -130,6 +134,11 @@
} else {
emailReg = null;
}
+
+ maxAuthTokenLifetime =
+ Duration.ofMinutes(
+ ConfigUtil.getTimeUnit(cfg, "auth", null, "maxAuthTokenLifetime", 0, TimeUnit.MINUTES));
+ maxAuthTokensPerAccount = cfg.getInt("auth", "maxAuthTokensPerAccount", 10);
}
private static List<OpenIdProviderPattern> toPatterns(Config cfg, String name) {
@@ -358,4 +367,15 @@
public boolean isAllowRegisterNewEmail() {
return allowRegisterNewEmail;
}
+
+ public Optional<Duration> getMaxAuthTokenLifetime() {
+ if (maxAuthTokenLifetime.isZero() || maxAuthTokenLifetime.isNegative()) {
+ return Optional.empty();
+ }
+ return Optional.of(maxAuthTokenLifetime);
+ }
+
+ public int getMaxAuthTokensPerAccount() {
+ return maxAuthTokensPerAccount;
+ }
}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 184816f..2b3a322 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -109,6 +109,7 @@
import com.google.gerrit.server.account.GroupControl;
import com.google.gerrit.server.account.GroupIncludeCacheImpl;
import com.google.gerrit.server.account.ServiceUserClassifierImpl;
+import com.google.gerrit.server.account.VersionedAuthTokens;
import com.google.gerrit.server.account.VersionedAuthorizedKeys;
import com.google.gerrit.server.account.externalids.ExternalIdModule;
import com.google.gerrit.server.approval.ApprovalsUtil;
@@ -522,6 +523,7 @@
factory(MergedByPushOp.Factory.class);
factory(GitModules.Factory.class);
factory(VersionedAuthorizedKeys.Factory.class);
+ factory(VersionedAuthTokens.Factory.class);
factory(StoreSubmitRequirementsOp.Factory.class);
factory(FileEditsPredicate.Factory.class);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 3ac5989..ad271b6 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -54,6 +54,7 @@
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtil.MergeBase;
import com.google.gerrit.server.index.change.ChangeIndexer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
@@ -657,8 +658,8 @@
throw new MergeConflictException(
String.format(
"Rebasing change edit onto another patchset results in merge conflicts.\n\n"
- + "%s\n\n"
- + "Download the edit patchset and rebase manually to preserve changes.",
+ + "%s\n"
+ + "Download the edit patchset and rebase manually to preserve changes.\n",
MergeUtil.createConflictMessage(conflicts)));
}
@@ -675,6 +676,8 @@
revWalk,
objectInserter,
dc,
+ "BASE",
+ MergeBase.create(revWalk.parseCommit(basePatchSetCommitId)),
"PATCH SET",
basePatchSetCommit,
"EDIT",
@@ -695,7 +698,8 @@
new PersonIdent(currentEditCommit.getCommitterIdent(), timestamp));
CodeReviewCommit newEditCommit = revWalk.parseCommit(newEditCommitId);
- newEditCommit.setConflicts(basePatchSetCommit, editCommitId, filesWithGitConflicts);
+ newEditCommit.setConflicts(
+ basePatchSetCommitId, basePatchSetCommit, editCommitId, "resolve", filesWithGitConflicts);
return newEditCommit;
}
}
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index 2990aa8..9c05763 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -21,6 +21,7 @@
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.submit.CommitMergeStatus;
import java.io.IOException;
@@ -172,23 +173,70 @@
this.statusMessage = Optional.ofNullable(statusMessage);
}
- public void setNoConflicts() {
+ public void setNoConflictsForNonMergeCommit() {
this.conflicts =
PatchSet.Conflicts.create(
- Optional.empty(), Optional.empty(), /* containsConflicts= */ false);
+ /* base= */ Optional.empty(),
+ /* ours= */ Optional.empty(),
+ /* theirs= */ Optional.empty(),
+ /* mergeStrategy= */ Optional.empty(),
+ /* noBaseReason= */ Optional.of(NoMergeBaseReason.NO_MERGE_PERFORMED),
+ /* containsConflicts= */ false);
}
public void setConflicts(
- ObjectId ours, ObjectId theirs, @Nullable Set<String> filesWithGitConflicts) {
+ ObjectId base,
+ ObjectId ours,
+ ObjectId theirs,
+ String mergeStrategy,
+ @Nullable Set<String> filesWithGitConflicts) {
if (filesWithGitConflicts != null && !filesWithGitConflicts.isEmpty()) {
this.conflicts =
PatchSet.Conflicts.create(
- Optional.of(ours), Optional.of(theirs), /* containsConflicts= */ true);
+ Optional.of(base),
+ Optional.of(ours),
+ Optional.of(theirs),
+ Optional.of(mergeStrategy),
+ /* noBaseReason= */ Optional.empty(),
+ /* containsConflicts= */ true);
this.filesWithGitConflicts = ImmutableSet.copyOf(filesWithGitConflicts);
} else {
this.conflicts =
PatchSet.Conflicts.create(
- Optional.of(ours), Optional.of(theirs), /* containsConflicts= */ false);
+ Optional.of(base),
+ Optional.of(ours),
+ Optional.of(theirs),
+ Optional.of(mergeStrategy),
+ /* noBaseReason= */ Optional.empty(),
+ /* containsConflicts= */ false);
+ }
+ }
+
+ public void setConflictsBaseNotAvailable(
+ ObjectId ours,
+ ObjectId theirs,
+ String mergeStrategy,
+ NoMergeBaseReason noMergeBaseReason,
+ @Nullable Set<String> filesWithGitConflicts) {
+ if (filesWithGitConflicts != null && !filesWithGitConflicts.isEmpty()) {
+ this.conflicts =
+ PatchSet.Conflicts.create(
+ Optional.empty(),
+ Optional.of(ours),
+ Optional.of(theirs),
+ Optional.of(mergeStrategy),
+ Optional.of(noMergeBaseReason),
+ /* containsConflicts= */ true);
+ this.filesWithGitConflicts = ImmutableSet.copyOf(filesWithGitConflicts);
+ } else {
+ this.conflicts =
+ PatchSet.Conflicts.create(
+ Optional.empty(),
+ Optional.of(ours),
+ Optional.of(theirs),
+ Optional.of(mergeStrategy),
+ Optional.of(noMergeBaseReason),
+ /* containsConflicts= */ false);
}
}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index cb5dc9d..ba94062 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -307,7 +307,7 @@
// The revert commit is based on the commit that is being reverted and has the same tree as the
// parent of the commit that is being reverted. This means revert commit never contains any
// conflicts.
- revertCommit.setNoConflicts();
+ revertCommit.setNoConflictsForNonMergeCommit();
return revertCommit;
}
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index edc0846..1e4d14a 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -24,6 +24,7 @@
import com.google.auto.factory.AutoFactory;
import com.google.auto.factory.Provided;
+import com.google.auto.value.AutoOneOf;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -45,6 +46,7 @@
import com.google.gerrit.exceptions.InvalidMergeStrategyException;
import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -74,6 +76,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.StringJoiner;
import org.eclipse.jgit.attributes.AttributesNodeProvider;
import org.eclipse.jgit.diff.Sequence;
import org.eclipse.jgit.dircache.DirCache;
@@ -231,7 +234,8 @@
InvalidMergeStrategyException {
ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig, attributesNodeProvider);
- m.setBase(originalCommit.getParent(parentIndex));
+ RevCommit baseCommit = originalCommit.getParent(parentIndex);
+ m.setBase(baseCommit);
DirCache dc = DirCache.newInCore();
if (allowConflicts && m instanceof ResolveMerger) {
@@ -307,6 +311,8 @@
rw,
inserter,
dc,
+ "BASE",
+ MergeBase.create(baseCommit),
"HEAD",
mergeTip,
"CHANGE",
@@ -325,7 +331,8 @@
cherryPickCommit.setMessage(commitMsg);
matchAuthorToCommitterDate(project, cherryPickCommit);
CodeReviewCommit commit = rw.parseCommit(inserter.insert(cherryPickCommit));
- commit.setConflicts(mergeTip, originalCommit, filesWithGitConflicts);
+ commit.setConflicts(
+ baseCommit, mergeTip, originalCommit, mergeStrategyName(), filesWithGitConflicts);
logger.atFine().log("CherryPick commitId=%s", commit.name());
return commit;
}
@@ -335,6 +342,8 @@
RevWalk rw,
ObjectInserter ins,
DirCache dc,
+ String baseName,
+ MergeBase base,
String oursName,
RevCommit ours,
String theirsName,
@@ -342,12 +351,27 @@
Map<String, MergeResult<? extends Sequence>> mergeResults,
boolean diff3Format)
throws IOException {
+ int nameLength = Math.max(Math.max(oursName.length(), theirsName.length()), baseName.length());
+
+ String baseDescription =
+ switch (base.getKind()) {
+ case BASE_COMMIT -> {
+ rw.parseBody(base.baseCommit());
+ String baseMsg = base.baseCommit().getShortMessage();
+ yield String.format(
+ "%s %s",
+ base.baseCommit().getName(), baseMsg.substring(0, Math.min(baseMsg.length(), 60)));
+ }
+ case NO_BASE_REASON -> base.noBaseReason().getDescription();
+ };
+ String baseNameFormatted =
+ String.format("%-" + nameLength + "s (%s)", baseName, baseDescription);
+
rw.parseBody(ours);
rw.parseBody(theirs);
String oursMsg = ours.getShortMessage();
String theirsMsg = theirs.getShortMessage();
- int nameLength = Math.max(oursName.length(), theirsName.length());
String oursNameFormatted =
String.format(
"%-" + nameLength + "s (%s %s)",
@@ -370,9 +394,10 @@
// TODO(dborowitz): Respect inCoreLimit here.
buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024);
if (diff3Format) {
- fmt.formatMergeDiff3(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+ fmt.formatMergeDiff3(
+ buf, p, baseNameFormatted, oursNameFormatted, theirsNameFormatted, UTF_8);
} else {
- fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+ fmt.formatMerge(buf, p, baseNameFormatted, oursNameFormatted, theirsNameFormatted, UTF_8);
}
buf.close(); // Flush file and close for writes, but leave available for reading.
@@ -466,7 +491,9 @@
ObjectId tree;
ImmutableSet<String> filesWithGitConflicts;
+ MergeBase mergeBase;
if (m.merge(false, mergeTip, originalCommit)) {
+ mergeBase = MergeBase.create(rw, mergeStrategy, m.getBaseCommitId());
filesWithGitConflicts = null;
tree = m.getResultTreeId();
} else {
@@ -516,11 +543,14 @@
.map(Map.Entry::getKey)
.collect(toImmutableSet());
+ mergeBase = MergeBase.create(rw, mergeStrategy, m.getBaseCommitId());
tree =
mergeWithConflicts(
rw,
inserter,
dc,
+ "BASE",
+ mergeBase,
"TARGET BRANCH",
mergeTip,
"SOURCE BRANCH",
@@ -536,19 +566,37 @@
mergeCommit.setCommitter(committerIdent);
mergeCommit.setMessage(commitMsg);
CodeReviewCommit commit = rw.parseCommit(inserter.insert(mergeCommit));
- commit.setConflicts(mergeTip, originalCommit, filesWithGitConflicts);
+
+ switch (mergeBase.getKind()) {
+ case BASE_COMMIT ->
+ commit.setConflicts(
+ mergeBase.baseCommit(),
+ mergeTip,
+ originalCommit,
+ mergeStrategy,
+ filesWithGitConflicts);
+ case NO_BASE_REASON ->
+ commit.setConflictsBaseNotAvailable(
+ mergeTip,
+ originalCommit,
+ mergeStrategy,
+ mergeBase.noBaseReason(),
+ filesWithGitConflicts);
+ }
return commit;
}
- public static String createConflictMessage(List<String> conflicts) {
- if (conflicts.isEmpty()) {
- return "";
+ public static String createConflictMessage(List<String> filesWithConflicts) {
+ StringBuilder sb = new StringBuilder("merge conflict(s)");
+
+ // If the simple-two-way-in-core merge strategy was used, we don't know which files had
+ // conflicts and filesWithConflicts is empty.
+ if (!filesWithConflicts.isEmpty()) {
+ StringJoiner joiner = new StringJoiner("\n* ", ":\n* ", "\n");
+ filesWithConflicts.forEach(joiner::add);
+ sb.append(joiner.toString());
}
- StringBuilder sb = new StringBuilder("merge conflict(s):");
- for (String c : conflicts) {
- sb.append('\n').append(c);
- }
return sb.toString();
}
@@ -1177,4 +1225,46 @@
commit.getCommitter().getZoneId()));
}
}
+
+ @AutoOneOf(MergeBase.Kind.class)
+ public abstract static class MergeBase {
+ public enum Kind {
+ BASE_COMMIT,
+ NO_BASE_REASON
+ }
+
+ public abstract Kind getKind();
+
+ public abstract RevCommit baseCommit();
+
+ public abstract NoMergeBaseReason noBaseReason();
+
+ public static MergeBase create(
+ RevWalk rw, String mergeStrategy, @Nullable ObjectId baseCommitId) throws IOException {
+ if (baseCommitId != null) {
+ try {
+ RevCommit baseCommit = rw.parseCommit(baseCommitId);
+ return AutoOneOf_MergeUtil_MergeBase.baseCommit(baseCommit);
+ } catch (MissingObjectException e) {
+ // RecursiveMerger performs a content merge, if necessary across multiple bases, using
+ // recursion to compute a usable base and trying to parse this computed base fails with a
+ // MissingObjectException.
+ return AutoOneOf_MergeUtil_MergeBase.noBaseReason(NoMergeBaseReason.COMPUTED_BASE);
+ }
+ }
+
+ if ("ours".equals(mergeStrategy) || "theirs".equals(mergeStrategy)) {
+ return AutoOneOf_MergeUtil_MergeBase.noBaseReason(
+ NoMergeBaseReason.ONE_SIDED_MERGE_STRATEGY);
+ }
+
+ // baseCommitId is null if the merged commits do not have a common predecessor
+ // (e.g. if 2 initial commits or 2 commits with unrelated histories are merged)
+ return AutoOneOf_MergeUtil_MergeBase.noBaseReason(NoMergeBaseReason.NO_COMMON_ANCESTOR);
+ }
+
+ public static MergeBase create(RevCommit baseCommit) {
+ return AutoOneOf_MergeUtil_MergeBase.baseCommit(baseCommit);
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 5f815b8..84f3eab 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -90,7 +90,8 @@
public enum TaskKind {
INDEXING,
- RECEIVE_COMMITS;
+ RECEIVE_COMMITS,
+ MIGRATION;
}
/** Handle for a sub-task. */
diff --git a/java/com/google/gerrit/server/mail/EmailFactories.java b/java/com/google/gerrit/server/mail/EmailFactories.java
index cb9d541..27a1699 100644
--- a/java/com/google/gerrit/server/mail/EmailFactories.java
+++ b/java/com/google/gerrit/server/mail/EmailFactories.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.mail;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
@@ -23,6 +24,7 @@
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.AuthToken;
import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
import com.google.gerrit.server.mail.send.ChangeEmail;
import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
@@ -63,6 +65,9 @@
String REVIEW_REQUESTED = "newchange";
String KEY_ADDED = "addkey";
String KEY_DELETED = "deletekey";
+ String AUTH_TOKEN_UPDATED = "AuthTokenUpdate";
+ String AUTH_TOKEN_WILL_EXPIRE = "AuthTokenWillExpire";
+ String AUTH_TOKEN_EXPIRED = "AuthTokenExpired";
String PASSWORD_UPDATED = "HttpPasswordUpdate";
String INBOUND_EMAIL_REJECTED = "error";
String NEW_EMAIL_REGISTERED = "registernewemail";
@@ -82,6 +87,9 @@
case REVIEW_REQUESTED -> "Review Request";
case KEY_ADDED -> "Key Added";
case KEY_DELETED -> "Key Deleted";
+ case AUTH_TOKEN_UPDATED -> "Authentication Token Updated";
+ case AUTH_TOKEN_WILL_EXPIRE -> "Authentication Token Will Expire";
+ case AUTH_TOKEN_EXPIRED -> "Authentication Token Expired";
case PASSWORD_UPDATED -> "Password Updated";
case INBOUND_EMAIL_REJECTED -> "Error";
case NEW_EMAIL_REGISTERED -> "Email Registered";
@@ -147,6 +155,15 @@
/** Email decorator for adding gpg keys to the account. */
EmailDecorator createDeleteKeyEmail(IdentifiedUser user, List<String> gpgKeys);
+ /** Email decorator for auth token modification operations. */
+ EmailDecorator createAuthTokenUpdateEmail(IdentifiedUser user, String operation, String tokenId);
+
+ /** Email decorator for auth token with close expiration date. */
+ EmailDecorator createAuthTokenWillExpireEmail(Account account, AuthToken authToken);
+
+ /** Email decorator for expired auth tokens. */
+ EmailDecorator createAuthTokenExpiredEmail(Account account, AuthToken authToken);
+
/** Email decorator for password modification operations. */
EmailDecorator createHttpPasswordUpdateEmail(IdentifiedUser user, String operation);
diff --git a/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java
new file mode 100644
index 0000000..6a807c9
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java
@@ -0,0 +1,67 @@
+// 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.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that an auth token will expire soon. */
+@AutoFactory
+public class AuthTokenExpiredEmailDecorator implements EmailDecorator {
+ private OutgoingEmail email;
+
+ private final Account account;
+ private final AuthToken token;
+ private final MessageIdGenerator messageIdGenerator;
+
+ public AuthTokenExpiredEmailDecorator(
+ @Provided MessageIdGenerator messageIdGenerator, Account account, AuthToken token) {
+ this.messageIdGenerator = messageIdGenerator;
+ this.account = account;
+ this.token = token;
+ }
+
+ @Override
+ public void init(OutgoingEmail email) {
+ this.email = email;
+
+ email.setHeader(
+ "Subject",
+ String.format("[Gerrit Code Review] Authentication token '%s' has expired.", token.id()));
+ email.setMessageId(
+ messageIdGenerator.fromReasonAccountIdAndTimestamp(
+ "Auth_token_expired", account.id(), TimeUtil.now()));
+ email.addByAccountId(RecipientType.TO, account.id());
+ }
+
+ @Override
+ public void populateEmailContent() {
+ email.addSoyEmailDataParam("email", account.preferredEmail());
+ email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(account.id()));
+ email.addSoyEmailDataParam("expirationDate", token.expirationDate().get().toString());
+ email.addSoyEmailDataParam("tokenId", token.id());
+ email.addSoyEmailDataParam("authTokenSettingsUrl", email.getSettingsUrl("HTTPCredentials"));
+
+ email.appendText(email.textTemplate("AuthTokenExpired"));
+ if (email.useHtml()) {
+ email.appendHtml(email.soyHtmlTemplate("AuthTokenExpiredHtml"));
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AuthTokenUpdateEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AuthTokenUpdateEmailDecorator.java
new file mode 100644
index 0000000..a7be502
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AuthTokenUpdateEmailDecorator.java
@@ -0,0 +1,73 @@
+// 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.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that an auth token of their account was updated. */
+@AutoFactory
+public class AuthTokenUpdateEmailDecorator implements EmailDecorator {
+ private OutgoingEmail email;
+
+ private final IdentifiedUser user;
+ private final String operation;
+ private final String tokenId;
+ private final MessageIdGenerator messageIdGenerator;
+
+ public AuthTokenUpdateEmailDecorator(
+ @Provided MessageIdGenerator messageIdGenerator,
+ IdentifiedUser user,
+ String operation,
+ String tokenId) {
+ this.messageIdGenerator = messageIdGenerator;
+ this.user = user;
+ this.operation = operation;
+ this.tokenId = tokenId;
+ }
+
+ @Override
+ public void init(OutgoingEmail email) {
+ this.email = email;
+
+ email.setHeader("Subject", "[Gerrit Code Review] Authentication token was " + operation);
+ email.setMessageId(
+ messageIdGenerator.fromReasonAccountIdAndTimestamp(
+ "Auth_token_change", user.getAccountId(), TimeUtil.now()));
+ email.addByAccountId(RecipientType.TO, user.getAccountId());
+ }
+
+ @Override
+ public void populateEmailContent() {
+ email.addSoyEmailDataParam("email", getEmail());
+ email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+ email.addSoyEmailDataParam("operation", operation);
+ email.addSoyEmailDataParam("tokenId", tokenId);
+ email.addSoyEmailDataParam("authTokenSettingsUrl", email.getSettingsUrl("HTTPCredentials"));
+
+ email.appendText(email.textTemplate("AuthTokenUpdate"));
+ if (email.useHtml()) {
+ email.appendHtml(email.soyHtmlTemplate("AuthTokenUpdateHtml"));
+ }
+ }
+
+ private String getEmail() {
+ return user.getAccount().preferredEmail();
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AuthTokenWillExpireEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AuthTokenWillExpireEmailDecorator.java
new file mode 100644
index 0000000..d64337b
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AuthTokenWillExpireEmailDecorator.java
@@ -0,0 +1,68 @@
+// 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.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that an auth token will expire soon. */
+@AutoFactory
+public class AuthTokenWillExpireEmailDecorator implements EmailDecorator {
+ private OutgoingEmail email;
+
+ private final Account account;
+ private final AuthToken token;
+ private final MessageIdGenerator messageIdGenerator;
+
+ public AuthTokenWillExpireEmailDecorator(
+ @Provided MessageIdGenerator messageIdGenerator, Account account, AuthToken token) {
+ this.messageIdGenerator = messageIdGenerator;
+ this.account = account;
+ this.token = token;
+ }
+
+ @Override
+ public void init(OutgoingEmail email) {
+ this.email = email;
+
+ email.setHeader(
+ "Subject",
+ String.format(
+ "[Gerrit Code Review] Authentication token '%s' will expire soon.", token.id()));
+ email.setMessageId(
+ messageIdGenerator.fromReasonAccountIdAndTimestamp(
+ "Auth_token_will_expire", account.id(), TimeUtil.now()));
+ email.addByAccountId(RecipientType.TO, account.id());
+ }
+
+ @Override
+ public void populateEmailContent() {
+ email.addSoyEmailDataParam("email", account.preferredEmail());
+ email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(account.id()));
+ email.addSoyEmailDataParam("expirationDate", token.expirationDate().get().toString());
+ email.addSoyEmailDataParam("tokenId", token.id());
+ email.addSoyEmailDataParam("authTokenSettingsUrl", email.getSettingsUrl("HTTPCredentials"));
+
+ email.appendText(email.textTemplate("AuthTokenWillExpire"));
+ if (email.useHtml()) {
+ email.appendHtml(email.soyHtmlTemplate("AuthTokenWillExpireHtml"));
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
index 6a56e38..5c66ab1 100644
--- a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
+++ b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.mail.send;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
@@ -22,6 +23,7 @@
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.AuthToken;
import com.google.gerrit.server.mail.EmailFactories;
import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
@@ -43,6 +45,9 @@
private final ChangeEmailImplFactory changeEmailFactory;
private final AddKeyEmailDecoratorFactory addKeyEmailFactory;
private final DeleteKeyEmailDecoratorFactory deleteKeyEmailFactory;
+ private final AuthTokenUpdateEmailDecoratorFactory authTokenUpdateEmailFactory;
+ private final AuthTokenWillExpireEmailDecoratorFactory authTokenWillExpireEmailFactory;
+ private final AuthTokenExpiredEmailDecoratorFactory authTokenExpiredEmailFactory;
private final HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailFactory;
private final RegisterNewEmailDecoratorImplFactory registerNewEmailFactory;
private final OutgoingEmailFactory outgoingEmailFactory;
@@ -55,6 +60,9 @@
ChangeEmailImplFactory changeEmailFactory,
AddKeyEmailDecoratorFactory addKeyEmailFactory,
DeleteKeyEmailDecoratorFactory deleteKeyEmailFactory,
+ AuthTokenUpdateEmailDecoratorFactory authTokenUpdateEmailFactory,
+ AuthTokenWillExpireEmailDecoratorFactory authTokenWillExpireEmailFactory,
+ AuthTokenExpiredEmailDecoratorFactory authTokenExpiredEmailFactory,
HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailFactory,
RegisterNewEmailDecoratorImplFactory registerNewEmailFactory,
OutgoingEmailFactory outgoingEmailFactory) {
@@ -64,6 +72,9 @@
this.changeEmailFactory = changeEmailFactory;
this.addKeyEmailFactory = addKeyEmailFactory;
this.deleteKeyEmailFactory = deleteKeyEmailFactory;
+ this.authTokenUpdateEmailFactory = authTokenUpdateEmailFactory;
+ this.authTokenWillExpireEmailFactory = authTokenWillExpireEmailFactory;
+ this.authTokenExpiredEmailFactory = authTokenExpiredEmailFactory;
this.httpPasswordUpdateEmailFactory = httpPasswordUpdateEmailFactory;
this.registerNewEmailFactory = registerNewEmailFactory;
this.outgoingEmailFactory = outgoingEmailFactory;
@@ -158,6 +169,22 @@
}
@Override
+ public EmailDecorator createAuthTokenUpdateEmail(
+ IdentifiedUser user, String operation, String tokenId) {
+ return authTokenUpdateEmailFactory.create(user, operation, tokenId);
+ }
+
+ @Override
+ public EmailDecorator createAuthTokenWillExpireEmail(Account account, AuthToken authToken) {
+ return authTokenWillExpireEmailFactory.create(account, authToken);
+ }
+
+ @Override
+ public EmailDecorator createAuthTokenExpiredEmail(Account account, AuthToken authToken) {
+ return authTokenExpiredEmailFactory.create(account, authToken);
+ }
+
+ @Override
public EmailDecorator createHttpPasswordUpdateEmail(IdentifiedUser user, String operation) {
return httpPasswordUpdateEmailFactory.create(user, operation);
}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 7bc319f..cae4128 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -50,6 +50,12 @@
"AddKeyHtml.soy",
"AddToAttentionSet.soy",
"AddToAttentionSetHtml.soy",
+ "AuthTokenExpired.soy",
+ "AuthTokenExpiredHtml.soy",
+ "AuthTokenWillExpire.soy",
+ "AuthTokenWillExpireHtml.soy",
+ "AuthTokenUpdate.soy",
+ "AuthTokenUpdateHtml.soy",
"ChangeFooter.soy",
"ChangeFooterHtml.soy",
"ChangeHeader.soy",
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
index ba10648..bdadb81 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -19,6 +19,7 @@
/** Footers, that can be set in NoteDb commits. */
public class ChangeNoteFooters {
public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+ public static final FooterKey FOOTER_BASE = new FooterKey("Base");
public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
@@ -29,6 +30,8 @@
public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
+ public static final FooterKey FOOTER_MERGE_STRATEGY = new FooterKey("Merge-Strategy");
+ public static final FooterKey FOOTER_NO_BASE_REASON = new FooterKey("No-Base-Reason");
public static final FooterKey FOOTER_OURS = new FooterKey("Ours");
public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index b402d91..feb27a6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -17,6 +17,7 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BASE;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
@@ -28,6 +29,8 @@
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_MERGE_STRATEGY;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_NO_BASE_REASON;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_OURS;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
@@ -82,6 +85,7 @@
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.SubmitRecord.Label.Status;
import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
@@ -690,6 +694,10 @@
return parseSha1(commit, FOOTER_COMMIT);
}
+ private Optional<ObjectId> parseBase(ChangeNotesCommit commit) throws ConfigInvalidException {
+ return parseSha1(commit, FOOTER_BASE);
+ }
+
private Optional<ObjectId> parseOurs(ChangeNotesCommit commit) throws ConfigInvalidException {
return parseSha1(commit, FOOTER_OURS);
}
@@ -698,6 +706,27 @@
return parseSha1(commit, FOOTER_THEIRS);
}
+ private Optional<String> parseMergeStrategy(ChangeNotesCommit commit)
+ throws ConfigInvalidException {
+ return Optional.ofNullable(parseOneFooter(commit, FOOTER_MERGE_STRATEGY));
+ }
+
+ private Optional<NoMergeBaseReason> parseNoBaseReason(ChangeNotesCommit commit)
+ throws ConfigInvalidException {
+ String noBaseReasonFooter = parseOneFooter(commit, FOOTER_NO_BASE_REASON);
+
+ if (noBaseReasonFooter == null) {
+ return Optional.empty();
+ }
+
+ NoMergeBaseReason noBaseReason =
+ Enums.getIfPresent(NoMergeBaseReason.class, noBaseReasonFooter).orNull();
+ if (noBaseReason == null) {
+ throw invalidFooter(FOOTER_NO_BASE_REASON, noBaseReasonFooter);
+ }
+ return Optional.of(noBaseReason);
+ }
+
private Optional<ObjectId> parseSha1(ChangeNotesCommit commit, FooterKey footerKey)
throws ConfigInvalidException {
String sha = parseOneFooter(commit, footerKey);
@@ -720,6 +749,10 @@
return Optional.empty();
}
+ // base is missing if the patch sets has been created before Gerrit started to store the base
+ // for conflicts.
+ Optional<ObjectId> base = parseBase(commit);
+
Optional<ObjectId> ours = parseOurs(commit);
if (containsConflicts.get() && ours.isEmpty()) {
throw parseException(
@@ -734,7 +767,20 @@
FOOTER_THEIRS, FOOTER_CONTAINS_CONFLICTS.getName(), FOOTER_THEIRS);
}
- return Optional.of(PatchSet.Conflicts.create(ours, theirs, containsConflicts.get()));
+ // mergeStrategy is missing if the patch set has been created before Gerrit started to store the
+ // merge strategy for conflicts.
+ Optional<String> mergeStrategy = parseMergeStrategy(commit);
+
+ // noBaseReason is not set if a base is available or if the patch set has been created before
+ // Gerrit started to store the no base reasons for conflicts.
+ Optional<NoMergeBaseReason> noBaseReason = parseNoBaseReason(commit);
+ if (containsConflicts.get() && base.isEmpty() && noBaseReason.isEmpty()) {
+ noBaseReason = Optional.of(NoMergeBaseReason.HISTORIC_DATA_WITHOUT_BASE);
+ }
+
+ return Optional.of(
+ PatchSet.Conflicts.create(
+ base, ours, theirs, mergeStrategy, noBaseReason, containsConflicts.get()));
}
private Optional<Boolean> parseContainsConflicts(ChangeNotesCommit commit)
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index cb84193..c4878de 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -20,6 +20,7 @@
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BASE;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
@@ -31,6 +32,8 @@
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_MERGE_STRATEGY;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_NO_BASE_REASON;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_OURS;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
@@ -869,11 +872,18 @@
}
if (conflicts != null) {
+ conflicts.base().map(ObjectId::getName).ifPresent(base -> addFooter(msg, FOOTER_BASE, base));
conflicts.ours().map(ObjectId::getName).ifPresent(ours -> addFooter(msg, FOOTER_OURS, ours));
conflicts
.theirs()
.map(ObjectId::getName)
.ifPresent(theirs -> addFooter(msg, FOOTER_THEIRS, theirs));
+ conflicts
+ .mergeStrategy()
+ .ifPresent(mergeStrategy -> addFooter(msg, FOOTER_MERGE_STRATEGY, mergeStrategy));
+ conflicts
+ .noBaseReason()
+ .ifPresent(noBaseReason -> addFooter(msg, FOOTER_NO_BASE_REASON, noBaseReason));
addFooter(msg, FOOTER_CONTAINS_CONFLICTS, conflicts.containsConflicts());
}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index b5f3bc4..2e8aa49 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -28,6 +28,7 @@
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.InMemoryInserter;
import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtil.MergeBase;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.update.RepoView;
import com.google.inject.Inject;
@@ -44,6 +45,7 @@
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.ResolveMerger;
+import org.eclipse.jgit.merge.StrategyRecursive;
import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
@@ -281,6 +283,11 @@
rw,
nonFlushingInserter,
dc,
+ "BASE",
+ MergeBase.create(
+ rw,
+ mergeStrategy instanceof StrategyRecursive ? "recursive" : "resolve",
+ m.getBaseCommitId()),
"HEAD",
merge.getParent(0),
"BRANCH",
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index adf4bdd..36fb452 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -20,6 +20,7 @@
import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND;
import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND;
import static com.google.gerrit.server.account.AccountResource.Star.STAR_KIND;
+import static com.google.gerrit.server.account.AccountResource.TOKEN_KIND;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.RestApiModule;
@@ -34,6 +35,7 @@
DynamicMap.mapOf(binder(), ACCOUNT_KIND);
DynamicMap.mapOf(binder(), CAPABILITY_KIND);
DynamicMap.mapOf(binder(), EMAIL_KIND);
+ DynamicMap.mapOf(binder(), TOKEN_KIND);
DynamicMap.mapOf(binder(), SSH_KEY_KIND);
DynamicMap.mapOf(binder(), STAR_KIND);
DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
@@ -71,8 +73,6 @@
get(ACCOUNT_KIND, "name").to(GetName.class);
put(ACCOUNT_KIND, "name").to(PutName.class);
delete(ACCOUNT_KIND, "name").to(PutName.class);
- put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
- delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
get(ACCOUNT_KIND, "preferences").to(GetPreferences.class);
put(ACCOUNT_KIND, "preferences").to(SetPreferences.class);
get(ACCOUNT_KIND, "preferences.diff").to(GetDiffPreferences.class);
@@ -80,6 +80,13 @@
get(ACCOUNT_KIND, "preferences.edit").to(GetEditPreferences.class);
put(ACCOUNT_KIND, "preferences.edit").to(SetEditPreferences.class);
+ put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
+ delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
+
+ child(ACCOUNT_KIND, "tokens").to(TokensCollection.class);
+ create(TOKEN_KIND).to(CreateToken.class);
+ delete(TOKEN_KIND).to(DeleteToken.class);
+
child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
postOnCollection(SSH_KEY_KIND).to(AddSshKey.class);
get(SSH_KEY_KIND).to(GetSshKey.class);
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 74caf74..bccc12d 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -28,6 +28,7 @@
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.extensions.annotations.RequiresCapability;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.IdString;
@@ -42,6 +43,9 @@
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
import com.google.gerrit.server.account.VersionedAuthorizedKeys;
import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -58,10 +62,13 @@
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
+import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -79,6 +86,7 @@
private final Sequences seq;
private final GroupResolver groupResolver;
private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+ private final AuthTokenAccessor tokensAccessor;
private final SshKeyCache sshKeyCache;
private final Provider<AccountsUpdate> accountsUpdateProvider;
private final AccountLoader.Factory infoLoader;
@@ -87,12 +95,14 @@
private final OutgoingEmailValidator validator;
private final AuthConfig authConfig;
private final ExternalIdFactory externalIdFactory;
+ private final Optional<Duration> maxAuthTokenLifetime;
@Inject
CreateAccount(
Sequences seq,
GroupResolver groupResolver,
VersionedAuthorizedKeys.Accessor authorizedKeys,
+ AuthTokenAccessor tokensAccessor,
SshKeyCache sshKeyCache,
@UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
AccountLoader.Factory infoLoader,
@@ -104,6 +114,7 @@
this.seq = seq;
this.groupResolver = groupResolver;
this.authorizedKeys = authorizedKeys;
+ this.tokensAccessor = tokensAccessor;
this.sshKeyCache = sshKeyCache;
this.accountsUpdateProvider = accountsUpdateProvider;
this.infoLoader = infoLoader;
@@ -112,6 +123,7 @@
this.validator = validator;
this.authConfig = authConfig;
this.externalIdFactory = externalIdFactory;
+ this.maxAuthTokenLifetime = authConfig.getMaxAuthTokenLifetime();
}
@Override
@@ -122,7 +134,8 @@
UnprocessableEntityException,
IOException,
ConfigInvalidException,
- PermissionBackendException {
+ PermissionBackendException,
+ InvalidAuthTokenException {
return apply(id, input != null ? input : new AccountInput());
}
@@ -132,7 +145,8 @@
UnprocessableEntityException,
IOException,
ConfigInvalidException,
- PermissionBackendException {
+ PermissionBackendException,
+ InvalidAuthTokenException {
String username = applyCaseOfUsername(id.get());
if (input.username != null && !username.equals(applyCaseOfUsername(input.username))) {
throw new BadRequestException("username must match URL");
@@ -157,7 +171,7 @@
extIds.add(externalIdFactory.createEmail(accountId, input.email));
}
- extIds.add(externalIdFactory.createUsername(username, accountId, input.httpPassword));
+ extIds.add(externalIdFactory.createUsername(username, accountId));
externalIdCreators.runEach(c -> extIds.addAll(c.create(accountId, username, input.email)));
try {
@@ -197,6 +211,28 @@
}
}
+ Optional<Instant> defaultExpiration = Optional.empty();
+ if (maxAuthTokenLifetime.isPresent()) {
+ defaultExpiration = Optional.of(Instant.now().plus(maxAuthTokenLifetime.get()));
+ }
+
+ List<AuthToken> tokens = new ArrayList<>();
+ if (input.tokens != null) {
+ for (AuthTokenInput token : input.tokens) {
+ tokens.add(
+ AuthToken.createWithPlainToken(
+ token.id, token.token, CreateToken.getExpirationInstant(token, defaultExpiration)));
+ }
+ }
+
+ if (input.httpPassword != null) {
+ tokens.add(AuthToken.createWithPlainToken("legacy", input.httpPassword));
+ }
+
+ if (!tokens.isEmpty()) {
+ tokensAccessor.addTokens(accountId, tokens);
+ }
+
AccountLoader loader = infoLoader.create(true);
AccountInfo info = loader.get(accountId);
loader.fill();
diff --git a/java/com/google/gerrit/server/restapi/account/CreateToken.java b/java/com/google/gerrit/server/restapi/account/CreateToken.java
new file mode 100644
index 0000000..3fb4071
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/CreateToken.java
@@ -0,0 +1,215 @@
+// 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.account;
+
+import static com.google.gerrit.server.mail.EmailFactories.AUTH_TOKEN_UPDATED;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.mail.EmailFactories;
+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.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * REST endpoint to set an authentication token of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT
+ * /accounts/<account-identifier>/tokens/<token-identifier>} requests.
+ *
+ * <p>Gerrit only stores the hash of the token, hence if a token was set it's not possible to get it
+ * back from Gerrit.
+ */
+@Singleton
+public class CreateToken
+ implements RestCollectionCreateView<AccountResource, AccountResource.Token, AuthTokenInput> {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private static final int LEN = 31;
+ private static final SecureRandom rng;
+
+ static {
+ try {
+ rng = SecureRandom.getInstance("SHA1PRNG");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("Cannot create RNG for password generator", e);
+ }
+ }
+
+ private final Provider<CurrentUser> self;
+ private final PermissionBackend permissionBackend;
+ private final EmailFactories emailFactories;
+ private final AuthTokenAccessor tokensAccessor;
+ private final Optional<Duration> maxAuthTokenLifetime;
+
+ @Inject
+ CreateToken(
+ Provider<CurrentUser> self,
+ PermissionBackend permissionBackend,
+ EmailFactories emailFactories,
+ AuthTokenAccessor tokensAccessor,
+ AuthConfig authConfig) {
+ this.self = self;
+ this.permissionBackend = permissionBackend;
+ this.emailFactories = emailFactories;
+ this.tokensAccessor = tokensAccessor;
+
+ this.maxAuthTokenLifetime = authConfig.getMaxAuthTokenLifetime();
+ }
+
+ @Override
+ @CanIgnoreReturnValue
+ public Response<AuthTokenInfo> apply(AccountResource rsrc, IdString id, AuthTokenInput input)
+ throws IOException,
+ ConfigInvalidException,
+ PermissionBackendException,
+ BadRequestException,
+ InvalidAuthTokenException,
+ RestApiException {
+ if (!self.get().hasSameAccountId(rsrc.getUser())) {
+ permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+ }
+
+ if (rsrc.getUser().getUserName().isEmpty()) {
+ throw new ResourceConflictException("A username is required to use basic authentication.");
+ }
+
+ if (input == null) {
+ input = new AuthTokenInput();
+ }
+
+ if (input.id != null && !input.id.equals(id.get())) {
+ throw new ResourceConflictException("Token ID must match in URL and input");
+ }
+
+ String newToken;
+ if (Strings.isNullOrEmpty(input.token)) {
+ newToken = generate();
+ } else {
+ // Only administrators can explicitly set a token.
+ permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+ newToken = input.token;
+ }
+
+ Optional<Instant> defaultExpiration = Optional.empty();
+ if (maxAuthTokenLifetime.isPresent()) {
+ defaultExpiration = Optional.of(Instant.now().plus(maxAuthTokenLifetime.get()));
+ }
+
+ return apply(
+ rsrc.getUser(), id.get(), newToken, getExpirationInstant(input, defaultExpiration));
+ }
+
+ @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
+ public Response<AuthTokenInfo> apply(
+ IdentifiedUser user, String id, String newToken, Optional<Instant> expiration)
+ throws IOException, ConfigInvalidException, RestApiException {
+ AuthToken token;
+ try {
+ token = tokensAccessor.addPlainToken(user.getAccountId(), id, newToken, expiration);
+ } catch (InvalidAuthTokenException e) {
+ throw new BadRequestException(e.getMessage(), e);
+ }
+ try {
+ emailFactories
+ .createOutgoingEmail(
+ AUTH_TOKEN_UPDATED, emailFactories.createAuthTokenUpdateEmail(user, "added", id))
+ .send();
+ } catch (EmailException e) {
+ logger.atSevere().withCause(e).log(
+ "Cannot send HttpPassword update message to %s", user.getAccount().preferredEmail());
+ }
+
+ AuthTokenInfo info = new AuthTokenInfo();
+ info.id = token.id();
+ info.token = newToken;
+ if (token.expirationDate().isPresent()) {
+ info.expiration = Timestamp.from(token.expirationDate().get());
+ }
+ return Response.created(info);
+ }
+
+ public static Optional<Instant> getExpirationInstant(
+ AuthTokenInput input, Optional<Instant> defaultExpiration) throws BadRequestException {
+ return getExpirationInstant(input.lifetime, defaultExpiration);
+ }
+
+ public static Optional<Instant> getExpirationInstant(
+ String lifetime, Optional<Instant> defaultExpiration) throws BadRequestException {
+ if (Strings.isNullOrEmpty(lifetime)) {
+ return defaultExpiration;
+ }
+ long lifetimeMinutes;
+ try {
+ lifetimeMinutes = ConfigUtil.getTimeUnit(lifetime, 0, TimeUnit.MINUTES);
+ } catch (IllegalArgumentException e) {
+ throw new BadRequestException("Invalid lifetime: " + lifetime, e);
+ }
+ if (lifetimeMinutes <= 0) {
+ throw new BadRequestException("Lifetime must be larger than 0");
+ }
+ return Optional.of(Instant.now().plus(lifetimeMinutes, ChronoUnit.MINUTES));
+ }
+
+ @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
+ public static String generate() {
+ byte[] rand = new byte[LEN];
+ rng.nextBytes(rand);
+
+ byte[] enc = BaseEncoding.base64().encode(rand).getBytes(UTF_8);
+ StringBuilder r = new StringBuilder(enc.length);
+ for (int i = 0; i < enc.length; i++) {
+ if (enc[i] == '=') {
+ break;
+ }
+ r.append((char) enc[i]);
+ }
+ return r.toString();
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
index f115bce..7239075 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
@@ -41,6 +41,7 @@
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.AccountSshKey;
import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthTokenAccessor;
import com.google.gerrit.server.account.VersionedAuthorizedKeys;
import com.google.gerrit.server.change.AccountPatchReviewStore;
import com.google.gerrit.server.config.AccountConfig;
@@ -93,6 +94,7 @@
private final PublicKeyStoreUtil publicKeyStoreUtil;
private final AccountConfig accountConfig;
private final Provider<GroupsUpdate> groupsUpdateProvider;
+ private final AuthTokenAccessor tokenAccessor;
@Inject
public DeleteAccount(
@@ -110,7 +112,8 @@
ChangeEditUtil changeEditUtil,
PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
PublicKeyStoreUtil publicKeyStoreUtil,
- AccountConfig accountConfig) {
+ AccountConfig accountConfig,
+ AuthTokenAccessor tokenAccessor) {
this.self = self;
this.serverIdent = serverIdent;
this.accountsUpdateProvider = accountsUpdateProvider;
@@ -126,6 +129,7 @@
this.publicKeyStoreUtil = publicKeyStoreUtil;
this.accountConfig = accountConfig;
this.groupsUpdateProvider = groupsUpdateProvider;
+ this.tokenAccessor = tokenAccessor;
}
@Override
@@ -146,6 +150,7 @@
deleteSshKeys(user);
deleteStarredChanges(userId);
deleteChangeEdits(userId);
+ tokenAccessor.deleteAllTokens(user.getAccountId());
deleteDraftCommentsUtil.deleteDraftComments(user, null);
accountPatchReviewStore.run(a -> a.clearReviewedBy(userId));
removeUserFromGroups(user);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteToken.java b/java/com/google/gerrit/server/restapi/account/DeleteToken.java
new file mode 100644
index 0000000..2efbc5c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteToken.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.restapi.account;
+
+import static com.google.gerrit.server.mail.EmailFactories.AUTH_TOKEN_UPDATED;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.mail.EmailFactories;
+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 org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+/**
+ * REST endpoint to delete a token of an account.
+ *
+ * <p>This REST endpoint handles {@code DELETE /accounts/<account-identifier>/tokens/<token-id>}
+ * requests.
+ */
+@Singleton
+public class DeleteToken implements RestModifyView<AccountResource.Token, Input> {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final Provider<CurrentUser> self;
+ private final PermissionBackend permissionBackend;
+ private final AuthTokenAccessor tokenAccessor;
+ private final EmailFactories emailFactories;
+
+ @Inject
+ DeleteToken(
+ Provider<CurrentUser> self,
+ PermissionBackend permissionBackend,
+ AuthTokenAccessor tokenAccessor,
+ EmailFactories emailFactories) {
+ this.self = self;
+ this.permissionBackend = permissionBackend;
+ this.tokenAccessor = tokenAccessor;
+ this.emailFactories = emailFactories;
+ }
+
+ @Override
+ public Response<String> apply(AccountResource.Token rsrc, Input input)
+ throws AuthException,
+ RepositoryNotFoundException,
+ IOException,
+ ConfigInvalidException,
+ PermissionBackendException,
+ InvalidAuthTokenException {
+ return apply(rsrc.getUser(), rsrc.getId(), true);
+ }
+
+ @CanIgnoreReturnValue
+ public Response<String> apply(IdentifiedUser user, String id, boolean notify)
+ throws RepositoryNotFoundException,
+ IOException,
+ ConfigInvalidException,
+ AuthException,
+ PermissionBackendException,
+ InvalidAuthTokenException {
+ if (!self.get().hasSameAccountId(user)) {
+ permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+ }
+ Account.Id accountId = user.getAccountId();
+ tokenAccessor.deleteToken(accountId, id);
+ if (notify) {
+ try {
+ emailFactories
+ .createOutgoingEmail(
+ AUTH_TOKEN_UPDATED, emailFactories.createAuthTokenUpdateEmail(user, "deleted", id))
+ .send();
+ } catch (EmailException e) {
+ logger.atSevere().withCause(e).log(
+ "Cannot send token deletion message to %s", user.getAccount().preferredEmail());
+ }
+ }
+
+ return Response.none();
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetTokens.java b/java/com/google/gerrit/server/restapi/account/GetTokens.java
new file mode 100644
index 0000000..7afe9c3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/GetTokens.java
@@ -0,0 +1,89 @@
+// 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.account;
+
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+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.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+
+/**
+ * REST endpoint to list the tokens of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/tokens/} requests.
+ */
+@Singleton
+public class GetTokens implements RestReadView<AccountResource> {
+ private final Provider<CurrentUser> self;
+ private final PermissionBackend permissionBackend;
+ private final AuthTokenAccessor tokenAccessor;
+
+ @Inject
+ GetTokens(
+ Provider<CurrentUser> self,
+ PermissionBackend permissionBackend,
+ AuthTokenAccessor tokenAccessor) {
+ this.self = self;
+ this.permissionBackend = permissionBackend;
+ this.tokenAccessor = tokenAccessor;
+ }
+
+ @Override
+ public Response<List<AuthTokenInfo>> apply(AccountResource rsrc)
+ throws AuthException,
+ PermissionBackendException,
+ RepositoryNotFoundException,
+ IOException,
+ ConfigInvalidException {
+ if (!self.get().hasSameAccountId(rsrc.getUser())) {
+ permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+ }
+ return Response.ok(apply(rsrc.getUser()));
+ }
+
+ public List<AuthTokenInfo> apply(IdentifiedUser user) {
+ List<AuthTokenInfo> authTokenInfos = new ArrayList<>();
+ for (AuthToken token : tokenAccessor.getTokens(user.getAccountId())) {
+ authTokenInfos.add(newTokenInfo(token));
+ }
+ return authTokenInfos;
+ }
+
+ public static AuthTokenInfo newTokenInfo(AuthToken token) {
+ AuthTokenInfo info = new AuthTokenInfo();
+ info.id = token.id();
+ if (token.expirationDate().isPresent()) {
+ info.expiration = Timestamp.from(token.expirationDate().get());
+ }
+ return info;
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index a3478f7..06579ac 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -14,26 +14,28 @@
package com.google.gerrit.server.restapi.account;
+import static com.google.gerrit.server.account.DirectAuthTokenAccessor.LEGACY_ID;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
import static com.google.gerrit.server.mail.EmailFactories.PASSWORD_UPDATED;
-import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
-import com.google.gerrit.common.UsedAt;
import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.common.HttpPasswordInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.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.UserInitiated;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.account.PasswordMigrator;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdFactory;
import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -46,8 +48,6 @@
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -61,54 +61,53 @@
* possible to get it back from Gerrit.
*/
@Singleton
+@Deprecated
public class PutHttpPassword implements RestModifyView<AccountResource, HttpPasswordInput> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private static final int LEN = 31;
- private static final SecureRandom rng;
-
- static {
- try {
- rng = SecureRandom.getInstance("SHA1PRNG");
- } catch (NoSuchAlgorithmException e) {
- throw new IllegalStateException("Cannot create RNG for password generator", e);
- }
- }
-
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
- private final ExternalIds externalIds;
+ private final AuthTokenAccessor tokenAccessor;
private final Provider<AccountsUpdate> accountsUpdateProvider;
- private final EmailFactories emailFactories;
+ private final ExternalIds externalIds;
private final ExternalIdFactory externalIdFactory;
private final ExternalIdKeyFactory externalIdKeyFactory;
+ private final CreateToken putToken;
+ private final DeleteToken deleteToken;
+ private final EmailFactories emailFactories;
@Inject
PutHttpPassword(
Provider<CurrentUser> self,
PermissionBackend permissionBackend,
- ExternalIds externalIds,
+ AuthTokenAccessor tokenAccessor,
@UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
- EmailFactories emailFactories,
+ ExternalIds externalIds,
ExternalIdFactory externalIdFactory,
- ExternalIdKeyFactory externalIdKeyFactory) {
+ ExternalIdKeyFactory externalIdKeyFactory,
+ CreateToken putToken,
+ DeleteToken deleteToken,
+ EmailFactories emailFactories) {
this.self = self;
this.permissionBackend = permissionBackend;
- this.externalIds = externalIds;
+ this.tokenAccessor = tokenAccessor;
this.accountsUpdateProvider = accountsUpdateProvider;
- this.emailFactories = emailFactories;
+ this.externalIds = externalIds;
this.externalIdFactory = externalIdFactory;
this.externalIdKeyFactory = externalIdKeyFactory;
+ this.putToken = putToken;
+ this.deleteToken = deleteToken;
+ this.emailFactories = emailFactories;
}
@Override
public Response<String> apply(AccountResource rsrc, HttpPasswordInput input)
- throws AuthException,
- ResourceNotFoundException,
- ResourceConflictException,
- IOException,
+ throws IOException,
ConfigInvalidException,
- PermissionBackendException {
+ PermissionBackendException,
+ BadRequestException,
+ RestApiException,
+ InvalidAuthTokenException {
if (!self.get().hasSameAccountId(rsrc.getUser())) {
permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
}
@@ -116,70 +115,52 @@
if (input == null) {
input = new HttpPasswordInput();
}
+
input.httpPassword = Strings.emptyToNull(input.httpPassword);
+ boolean isDeleteOp = !input.generate && input.httpPassword == null;
- String newPassword;
- if (input.generate) {
- newPassword = generate();
- } else if (input.httpPassword == null) {
- newPassword = null;
- } else {
- // Only administrators can explicitly set the password.
- permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
- newPassword = input.httpPassword;
- }
- return apply(rsrc.getUser(), newPassword);
- }
-
- @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
- public Response<String> apply(IdentifiedUser user, String newPassword)
- throws ResourceNotFoundException,
- ResourceConflictException,
- IOException,
- ConfigInvalidException {
- String userName =
- user.getUserName().orElseThrow(() -> new ResourceConflictException("username must be set"));
- Optional<ExternalId> optionalExtId =
- externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, userName));
- ExternalId extId = optionalExtId.orElseThrow(ResourceNotFoundException::new);
- accountsUpdateProvider
- .get()
- .update(
- "Set HTTP Password via API",
- extId.accountId(),
- u ->
- u.updateExternalId(
- externalIdFactory.createWithPassword(
- extId.key(), extId.accountId(), extId.email(), newPassword)));
-
- try {
- emailFactories
- .createOutgoingEmail(
- PASSWORD_UPDATED,
- emailFactories.createHttpPasswordUpdateEmail(
- user, newPassword == null ? "deleted" : "added or updated"))
- .send();
- } catch (EmailException e) {
- logger.atSevere().withCause(e).log(
- "Cannot send HttpPassword update message to %s", user.getAccount().preferredEmail());
- }
-
- return Strings.isNullOrEmpty(newPassword) ? Response.none() : Response.ok(newPassword);
- }
-
- @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
- public static String generate() {
- byte[] rand = new byte[LEN];
- rng.nextBytes(rand);
-
- byte[] enc = BaseEncoding.base64().encode(rand).getBytes(UTF_8);
- StringBuilder r = new StringBuilder(enc.length);
- for (int i = 0; i < enc.length; i++) {
- if (enc[i] == '=') {
- break;
+ Response<String> resp = Response.none();
+ if (tokenAccessor.getToken(rsrc.getUser().getAccountId(), LEGACY_ID).isPresent()) {
+ Optional<ExternalId> optionalExtId =
+ externalIds.get(
+ externalIdKeyFactory.create(SCHEME_USERNAME, rsrc.getUser().getUserName().get()));
+ ExternalId extId = optionalExtId.orElseThrow(ResourceNotFoundException::new);
+ accountsUpdateProvider
+ .get()
+ .update(
+ "Remove HTTP Password",
+ extId.accountId(),
+ u ->
+ u.updateExternalId(
+ externalIdFactory.createWithEmail(
+ extId.key(), extId.accountId(), extId.email())));
+ if (isDeleteOp) {
+ try {
+ emailFactories
+ .createOutgoingEmail(
+ PASSWORD_UPDATED,
+ emailFactories.createHttpPasswordUpdateEmail(rsrc.getUser(), "deleted"))
+ .send();
+ } catch (EmailException e) {
+ logger.atSevere().withCause(e).log(
+ "Cannot send HttpPassword update message to %s",
+ rsrc.getUser().getAccount().preferredEmail());
+ }
}
- r.append((char) enc[i]);
+ } else {
+ resp = deleteToken.apply(rsrc.getUser(), PasswordMigrator.DEFAULT_ID, isDeleteOp);
}
- return r.toString();
+
+ if (isDeleteOp) {
+ return resp;
+ }
+
+ AuthTokenInput authTokenInput = new AuthTokenInput();
+ authTokenInput.token = input.httpPassword;
+ return Response.created(
+ putToken
+ .apply(rsrc, IdString.fromDecoded(PasswordMigrator.DEFAULT_ID), authTokenInput)
+ .value()
+ .token);
}
}
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index f295389..bc97b1e 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -122,7 +122,7 @@
.update(
"Set Username via API",
accountId,
- u -> u.addExternalId(externalIdFactory.create(key, accountId, null, null)));
+ u -> u.addExternalId(externalIdFactory.create(key, accountId)));
} catch (DuplicateKeyException dupeErr) {
// If we are using this identity, don't report the exception.
Optional<ExternalId> other = externalIds.get(key);
diff --git a/java/com/google/gerrit/server/restapi/account/TokensCollection.java b/java/com/google/gerrit/server/restapi/account/TokensCollection.java
new file mode 100644
index 0000000..c7eb58c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/TokensCollection.java
@@ -0,0 +1,87 @@
+// 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.account;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+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 org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class TokensCollection implements ChildCollection<AccountResource, AccountResource.Token> {
+ private final DynamicMap<RestView<AccountResource.Token>> views;
+ private final GetTokens list;
+ private final Provider<CurrentUser> self;
+ private final PermissionBackend permissionBackend;
+ private final AuthTokenAccessor tokenAccessor;
+
+ @Inject
+ TokensCollection(
+ DynamicMap<RestView<AccountResource.Token>> views,
+ GetTokens list,
+ Provider<CurrentUser> self,
+ PermissionBackend permissionBackend,
+ AuthTokenAccessor tokenAccessor) {
+ this.views = views;
+ this.list = list;
+ this.self = self;
+ this.permissionBackend = permissionBackend;
+ this.tokenAccessor = tokenAccessor;
+ }
+
+ @Override
+ public RestView<AccountResource> list() {
+ return list;
+ }
+
+ @Override
+ public AccountResource.Token parse(AccountResource rsrc, IdString id)
+ throws ResourceNotFoundException,
+ PermissionBackendException,
+ AuthException,
+ IOException,
+ ConfigInvalidException {
+ if (!self.get().hasSameAccountId(rsrc.getUser())) {
+ permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+ }
+
+ for (AuthToken token : tokenAccessor.getTokens(rsrc.getUser().getAccountId())) {
+ if (token.id().equals(id.get())) {
+ return new AccountResource.Token(rsrc.getUser(), id.get());
+ }
+ }
+
+ throw new ResourceNotFoundException(id);
+ }
+
+ @Override
+ public DynamicMap<RestView<AccountResource.Token>> views() {
+ return views;
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 34401ca..93b77c1 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -715,7 +715,7 @@
rw.parseCommit(
CommitUtil.createCommitWithTree(
oi, authorIdent, committerIdent, parents, commitMessage, treeId));
- commit.setNoConflicts();
+ commit.setNoConflictsForNonMergeCommit();
return commit;
}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index 39be75e..105c478 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -73,6 +73,7 @@
post(INDEX_VERSION_KIND, "snapshot").to(SnapshotIndexVersion.class);
post(INDEX_VERSION_KIND, "reindex").to(ReindexIndexVersion.class);
+ post(CONFIG_KIND, "passwords.to.tokens").to(MigratePasswordsToTokens.class);
// The caches and summary REST endpoints are bound via RestCacheAdminModule.
}
}
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 7683b58..4ebd319 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -183,6 +183,9 @@
info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
info.switchAccountUrl = authConfig.getSwitchAccountUrl();
info.gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
+ if (authConfig.getMaxAuthTokenLifetime().isPresent()) {
+ info.maxTokenLifetime = authConfig.getMaxAuthTokenLifetime().get().toMinutes();
+ }
if (info.useContributorAgreements != null) {
ImmutableCollection<ContributorAgreement> agreements =
diff --git a/java/com/google/gerrit/server/restapi/config/MigratePasswordsToTokens.java b/java/com/google/gerrit/server/restapi/config/MigratePasswordsToTokens.java
new file mode 100644
index 0000000..a5756ce
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/MigratePasswordsToTokens.java
@@ -0,0 +1,66 @@
+// 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 com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+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.account.PasswordMigrator;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.restapi.account.CreateToken;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.Future;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class MigratePasswordsToTokens
+ implements RestModifyView<
+ ConfigResource, com.google.gerrit.server.restapi.config.MigratePasswordsToTokens.Input> {
+ static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final WorkQueue workQueue;
+ private final PasswordMigrator.Factory passwordMigratorFactory;
+
+ static class Input {
+ String lifetime;
+ }
+
+ @Inject
+ public MigratePasswordsToTokens(
+ WorkQueue workQueue, PasswordMigrator.Factory passwordMigratorFactory) {
+ this.workQueue = workQueue;
+ this.passwordMigratorFactory = passwordMigratorFactory;
+ }
+
+ @Override
+ public Response<?> apply(ConfigResource resource, Input input)
+ throws AuthException, BadRequestException, ResourceConflictException, Exception {
+ Optional<Instant> expirationDate =
+ CreateToken.getExpirationInstant(input.lifetime, Optional.empty());
+ @SuppressWarnings("unused")
+ Future<?> possiblyIgnoredError =
+ workQueue.getDefaultQueue().submit(passwordMigratorFactory.create(expirationDate));
+ return Response.accepted("Password Migrator task added to work queue.");
+ }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 660b0df..6d2c0d1 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -16,15 +16,18 @@
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.extensions.annotations.RequiresCapability;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.restapi.account.CreateAccount;
import com.google.gerrit.sshd.CommandMetaData;
@@ -65,6 +68,16 @@
usage = "password for HTTP authentication")
private String httpPassword;
+ @Option(name = "--token", metaVar = "TOKEN", usage = "token for HTTP authentication")
+ private String token;
+
+ @Option(
+ name = "--token-id",
+ depends = {"--token"},
+ metaVar = "TOKEN",
+ usage = "ID for the authentication token")
+ private String tokenId;
+
@Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the user account")
private String username;
@@ -80,12 +93,25 @@
input.name = fullName;
input.sshKey = readSshKey();
input.httpPassword = httpPassword;
+
+ if (!Strings.isNullOrEmpty(token)) {
+ AuthTokenInput tokenInput = new AuthTokenInput();
+ if (!Strings.isNullOrEmpty(tokenId)) {
+ tokenInput.id = tokenId;
+ }
+ tokenInput.token = token;
+
+ input.tokens = List.of(tokenInput);
+ } else {
+ input.tokens = List.of();
+ }
+
input.groups = Lists.transform(groups, AccountGroup.Id::toString);
try {
@SuppressWarnings("unused")
var unused =
createAccount.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(username), input);
- } catch (RestApiException e) {
+ } catch (RestApiException | InvalidAuthTokenException e) {
throw die(e.getMessage());
}
}
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 86f327c..897bb20 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -23,6 +23,8 @@
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.extensions.api.accounts.EmailInput;
import com.google.gerrit.extensions.api.accounts.SshKeyInput;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.common.EmailInfo;
import com.google.gerrit.extensions.common.HttpPasswordInput;
import com.google.gerrit.extensions.common.Input;
@@ -43,10 +45,12 @@
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.restapi.account.AddSshKey;
import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.CreateToken;
import com.google.gerrit.server.restapi.account.DeleteActive;
import com.google.gerrit.server.restapi.account.DeleteEmail;
import com.google.gerrit.server.restapi.account.DeleteExternalIds;
import com.google.gerrit.server.restapi.account.DeleteSshKey;
+import com.google.gerrit.server.restapi.account.DeleteToken;
import com.google.gerrit.server.restapi.account.GetEmails;
import com.google.gerrit.server.restapi.account.GetSshKeys;
import com.google.gerrit.server.restapi.account.PutActive;
@@ -61,6 +65,7 @@
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -125,6 +130,21 @@
@Option(name = "--generate-http-password", usage = "generate a new HTTP password for the account")
private boolean generateHttpPassword;
+ @Option(name = "--token", metaVar = "TOKEN", usage = "token for HTTP authentication")
+ private List<String> tokens = new ArrayList<>();
+
+ @Option(
+ name = "--generate-token",
+ metaVar = "TOKENID",
+ usage = "generate a new token for the account")
+ private List<String> tokenIdsToGenerate = new ArrayList<>();
+
+ @Option(
+ name = "--delete-token",
+ metaVar = "TOKENID",
+ usage = "delete token with given ID for the account")
+ private List<String> tokenIdsToDelete = new ArrayList<>();
+
@Option(
name = "--delete-external-id",
metaVar = "EXTERNALID",
@@ -147,6 +167,10 @@
@Inject private PutHttpPassword putHttpPassword;
+ @Inject private CreateToken createToken;
+
+ @Inject private DeleteToken deleteToken;
+
@Inject private PutActive putActive;
@Inject private DeleteActive deleteActive;
@@ -265,6 +289,25 @@
}
}
+ for (String token : tokens) {
+ AuthTokenInput tokenInput = new AuthTokenInput();
+ tokenInput.id = String.format("token_%d", Instant.now().toEpochMilli());
+ tokenInput.token = token;
+ createToken.apply(rsrc, IdString.fromDecoded(tokenInput.id), tokenInput);
+ }
+
+ for (String tokenId : tokenIdsToGenerate) {
+ AuthTokenInput tokenInput = new AuthTokenInput();
+ tokenInput.id = tokenId;
+ Response<AuthTokenInfo> resp =
+ createToken.apply(rsrc, IdString.fromDecoded(tokenInput.id), tokenInput);
+ stdout.print(String.format("New token (id = %s): %s", resp.value().id, resp.value().token));
+ }
+
+ for (String tokenId : tokenIdsToDelete) {
+ deleteToken.apply(rsrc.getUser(), tokenId, true);
+ }
+
if (active) {
@SuppressWarnings("unused")
var unused = putActive.apply(rsrc, null);
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 893a4d5..657e1f2 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -28,6 +28,7 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
import com.google.gerrit.auth.AuthModule;
import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.systemstatus.ServerInformation;
@@ -48,6 +49,8 @@
import com.google.gerrit.server.Sequence;
import com.google.gerrit.server.Sequence.LightweightGroups;
import com.google.gerrit.server.account.AccountCacheImpl;
+import com.google.gerrit.server.account.AuthTokenModule;
+import com.google.gerrit.server.account.CachingAuthTokenModule;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheImpl;
import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
@@ -235,6 +238,15 @@
install(new SuperprojectUpdateSubmissionListenerModule());
install(new WorkQueueModule());
+ boolean useAuthTokenCache =
+ authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP
+ || authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP_LDAP;
+ if (useAuthTokenCache) {
+ install(new CachingAuthTokenModule());
+ } else {
+ install(new AuthTokenModule());
+ }
+
bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
// It would be nice to use Jimfs for the SitePath, but the biggest blocker is that JGit does not
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index b0817fe..bcf401e 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -24,6 +24,8 @@
import com.google.gerrit.extensions.api.accounts.EmailInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
import com.google.gerrit.testing.FakeEmailSender;
import java.net.URI;
@@ -95,12 +97,15 @@
}
@Test
- public void messageIdHeaderFromPasswordUpdate() throws Exception {
+ public void messageIdHeaderFromAuthTokenUpdate() throws Exception {
sender.clear();
- String newPassword = gApi.accounts().self().generateHttpPassword();
- assertThat(newPassword).isNotNull();
+ AuthTokenInput token = new AuthTokenInput();
+ token.id = "testToken";
+ AuthTokenInfo tokenInfo = gApi.accounts().self().createToken(token);
+ assertThat(tokenInfo).isNotNull();
+ assertThat(tokenInfo.token).isNotNull();
assertThat(getMessageId(sender))
- .containsMatch("<HTTP_password_change-" + admin.id().toString() + ".*@.*>");
+ .containsMatch("<Auth_token_change-" + admin.id().toString() + ".*@.*>");
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 81e6d11..64f20d9 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -424,7 +424,7 @@
accountIndexedCounter.assertReindexOf(accountId, 1);
assertThat(getExternalIdsReader().byAccount(accountId))
.containsExactly(
- getExternalIdFactory().createUsername(input.username, accountId, null),
+ getExternalIdFactory().createUsername(input.username, accountId),
getExternalIdFactory().createEmail(accountId, input.email));
}
}
@@ -2812,7 +2812,8 @@
String newPassword = gApi.accounts().self().generateHttpPassword();
assertThat(newPassword).isNotNull();
assertThat(sender.getMessages()).hasSize(1);
- assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+ assertThat(sender.getMessages().get(0).body())
+ .contains("The authentication token with id \"default\" was added");
}
@Test
@@ -2822,7 +2823,8 @@
String newPassword = gApi.accounts().id(user.id().get()).generateHttpPassword();
assertThat(newPassword).isNotNull();
assertThat(sender.getMessages()).hasSize(1);
- assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+ assertThat(sender.getMessages().get(0).body())
+ .contains("The authentication token with id \"default\" was added");
}
@Test
@@ -2871,7 +2873,8 @@
assertThat(gApi.accounts().id(user.id().get()).setHttpPassword(httpPassword))
.isEqualTo(httpPassword);
assertThat(sender.getMessages()).hasSize(1);
- assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+ assertThat(sender.getMessages().get(0).body())
+ .contains("The authentication token with id \"default\" was added");
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index 4a70843..7889753 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -130,8 +130,11 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(currentMaster.getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(targetBranch.name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
// Verify the message that has been posted on the change.
@@ -231,7 +234,15 @@
assertThrows(
ResourceConflictException.class,
() -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ """
+ merge conflict(s):
+ * %s
+ """,
+ fileName));
}
@Test
@@ -305,8 +316,11 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(currentMaster.getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(targetBranch.name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
// Verify that the file content in the created patch set is correct.
@@ -324,7 +338,11 @@
+ ")\n"
+ targetContent
+ "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ initialHead.getName(), initialHead.getShortMessage())
+ : "")
+ "=======\n"
+ sourceContent
+ "\n"
@@ -436,8 +454,11 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isNotEqualTo(currentMaster.getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(parent);
assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
}
@@ -540,8 +561,11 @@
RevisionInfo currentRevision = changeInfo.getCurrentRevision();
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(baseChangeCommit);
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(baseChangeCommit);
assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 53e85a4..78e5060 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -126,6 +126,8 @@
@Test
public void rebaseChange() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
// Create two changes both with the same parent
PushOneCommit.Result r = createChange();
testRepo.reset("HEAD~1");
@@ -144,7 +146,13 @@
rebaseCall.call(r2.getChangeId());
verifyRebaseForChange(
- r2.getChange().getId(), commitThatIsBeingRebased.name(), r.getCommit().name(), true, 2);
+ r2.getChange().getId(),
+ initialHead,
+ commitThatIsBeingRebased.name(),
+ r.getCommit().name(),
+ "recursive",
+ true,
+ 2);
// Rebasing the second change again should fail
verifyChangeIsUpToDate(r2);
@@ -171,6 +179,9 @@
.content("B content")
.createV1();
+ ObjectId change1PatchSetCommit =
+ changeOperations.change(changeId1).currentPatchset().get().commitId();
+
// Delete the first change
gApi.changes().id(project.get(), changeId1.get()).delete();
@@ -181,7 +192,13 @@
rebaseCall.call(changeId2.toString());
verifyRebaseForChange(
- changeId2, commitThatIsBeingRebased, newBase, /* shouldHaveApproval= */ false, 2);
+ changeId2,
+ change1PatchSetCommit,
+ commitThatIsBeingRebased,
+ newBase,
+ "recursive",
+ /* shouldHaveApproval= */ false,
+ 2);
}
@Test
@@ -205,6 +222,9 @@
.content("B content")
.createV1();
+ ObjectId change1PatchSetCommit =
+ changeOperations.change(changeId1).currentPatchset().get().commitId();
+
// Delete the first change
gApi.changes().id(project.get(), changeId1.get()).delete();
@@ -225,7 +245,13 @@
rebaseCallWithInput.call(changeId2.toString(), rebaseInput);
verifyRebaseForChange(
- changeId2, commitThatIsBeingRebased, newBase, /* shouldHaveApproval= */ false, 2);
+ changeId2,
+ change1PatchSetCommit,
+ commitThatIsBeingRebased,
+ newBase,
+ "recursive",
+ /* shouldHaveApproval= */ false,
+ 2);
}
@Test
@@ -280,6 +306,8 @@
.file(file1)
.content("master content")
.createV1();
+ ObjectId baseChangeCommit =
+ changeOperations.change(baseChangeInMaster).currentPatchset().get().commitId();
approveAndSubmit(baseChangeInMaster);
// Create a change in the other branch and that touches file1 and creates file2.
@@ -333,9 +361,11 @@
verifyRebaseForChange(
mergeChangeId,
+ baseChangeCommit,
commitThatIsBeingRebased,
ImmutableList.of(
getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)),
+ "recursive",
/* shouldHaveApproval= */ true,
/* shouldHaveConflicts,= */ false,
/* expectedNumRevisions= */ 2);
@@ -433,10 +463,12 @@
.hasMessageThat()
.isEqualTo(
String.format(
- "Change %s could not be rebased due to a conflict during merge.\n"
- + "\n"
- + "merge conflict(s):\n"
- + "%s",
+ """
+ Change %s could not be rebased due to a conflict during merge.
+
+ merge conflict(s):
+ * %s
+ """,
mergeChangeId, file1));
}
@@ -493,15 +525,19 @@
gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
// Create a change in master that touches the file.
+ String baseChangeSubject = "base change";
String baseChangeBaseContent = "base content";
Change.Id baseChangeInMaster =
changeOperations
.newChange()
.project(project)
.branch("master")
+ .commitMessage(baseChangeSubject)
.file(file)
.content(baseChangeBaseContent)
.createV1();
+ ObjectId baseChangeCommit =
+ changeOperations.change(baseChangeInMaster).currentPatchset().get().commitId();
approveAndSubmit(baseChangeInMaster);
// Create a change in the other branch and that also touches the file.
@@ -567,8 +603,10 @@
String baseCommit = getCurrentRevision(newBaseChangeInMaster);
verifyRebaseForChange(
mergeChangeId,
+ baseChangeCommit,
commitThatIsBeingRebased,
ImmutableList.of(baseCommit, getCurrentRevision(changeInOtherBranch)),
+ "recursive",
/* shouldHaveApproval= */ false,
/* shouldHaveConflicts,= */ true,
/* expectedNumRevisions= */ 2);
@@ -583,7 +621,11 @@
+ ")\n"
+ mergeContent
+ "\n"
- + (useDiff3 ? String.format("||||||| BASE\n%s\n", baseChangeBaseContent) : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n%s\n",
+ baseChangeCommit.getName(), baseChangeSubject, baseChangeBaseContent)
+ : "")
+ "=======\n"
+ newBaseContent
+ "\n"
@@ -652,6 +694,8 @@
.file(file)
.content("master content")
.createV1();
+ ObjectId baseChangeCommit =
+ changeOperations.change(baseChangeInMaster).currentPatchset().get().commitId();
approveAndSubmit(baseChangeInMaster);
// Create a change in the other branch and that also touches the file.
@@ -701,9 +745,11 @@
verifyRebaseForChange(
mergeChangeId,
+ baseChangeCommit,
commitThatIsBeingRebased,
ImmutableList.of(
getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)),
+ strategy,
/* shouldHaveApproval= */ false,
/* shouldHaveConflicts,= */ false,
/* expectedNumRevisions= */ 2);
@@ -1267,6 +1313,8 @@
@Test
public void rebaseChangeWhenChecksRefExists() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
// Create two changes both with the same parent
PushOneCommit.Result r = createChange();
testRepo.reset("HEAD~1");
@@ -1290,8 +1338,10 @@
verifyRebaseForChange(
r2.getChange().getId(),
+ initialHead,
r2.getCommit().name(),
r.getCommit().name(),
+ "recursive",
/* shouldHaveApproval= */ false,
/* expectedNumRevisions= */ 2);
}
@@ -1315,25 +1365,31 @@
protected void verifyRebaseForChange(
Change.Id changeId,
+ ObjectId base,
String commitThatIsBeingRebased,
Change.Id baseChangeId,
+ String strategy,
boolean shouldHaveApproval)
throws RestApiException {
verifyRebaseForChange(
- changeId, commitThatIsBeingRebased, baseChangeId, shouldHaveApproval, 2);
+ changeId, base, commitThatIsBeingRebased, baseChangeId, strategy, shouldHaveApproval, 2);
}
protected void verifyRebaseForChange(
Change.Id changeId,
+ ObjectId base,
String commitThatIsBeingRebased,
Change.Id baseChangeId,
+ String strategy,
boolean shouldHaveApproval,
int expectedNumRevisions)
throws RestApiException {
verifyRebaseForChange(
changeId,
+ base,
commitThatIsBeingRebased,
ImmutableList.of(getCurrentRevision(baseChangeId)),
+ strategy,
shouldHaveApproval,
/* shouldHaveConflicts,= */ false,
expectedNumRevisions);
@@ -1341,15 +1397,19 @@
protected void verifyRebaseForChange(
Change.Id changeId,
+ ObjectId base,
String commitThatIsBeingRebased,
String parentCommit,
+ String strategy,
boolean shouldHaveApproval,
int expectedNumRevisions)
throws RestApiException {
verifyRebaseForChange(
changeId,
+ base,
commitThatIsBeingRebased,
ImmutableList.of(parentCommit),
+ strategy,
shouldHaveApproval, /* shouldHaveConflicts,= */
false,
expectedNumRevisions);
@@ -1357,8 +1417,10 @@
protected void verifyRebaseForChange(
Change.Id changeId,
+ ObjectId base,
String commitThatIsBeingRebased,
List<String> parentCommits,
+ String strategy,
boolean shouldHaveApproval,
boolean shouldHaveConflicts,
int expectedNumRevisions)
@@ -1372,8 +1434,11 @@
// check conflicts info
assertThat(r.conflicts).isNotNull();
+ assertThat(r.conflicts.base).isEqualTo(base.getName());
assertThat(r.conflicts.ours).isEqualTo(commitThatIsBeingRebased);
assertThat(r.conflicts.theirs).isEqualTo(parentCommits.get(0));
+ assertThat(r.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(r.conflicts.noBaseReason).isNull();
assertThat(r.conflicts.containsConflicts).isEqualTo(shouldHaveConflicts);
// ...and the parent should be correct
@@ -1464,6 +1529,8 @@
String baseContent = "base content";
String expectedContent = strategy.equals("theirs") ? baseContent : patchSetContent;
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
gApi.changes()
.id(r1.getChangeId())
@@ -1513,8 +1580,11 @@
RevisionInfo currentRevision = changeInfo.getCurrentRevision();
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(base.name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(patchSet.name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(base.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
// Verify that the file content in the created patch set is correct.
@@ -1559,6 +1629,8 @@
String baseSubject = "base change";
String baseContent = "base content";
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
gApi.changes()
.id(r1.getChangeId())
@@ -1608,8 +1680,11 @@
RevisionInfo currentRevision = changeInfo.getCurrentRevision();
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(base.name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(patchSet.name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(base.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
// Verify that the file content in the created patch set is correct.
@@ -1628,7 +1703,11 @@
+ ")\n"
+ patchSetContent
+ "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ initialHead.getName(), initialHead.getShortMessage())
+ : "")
+ "=======\n"
+ baseContent
+ "\n"
@@ -1675,8 +1754,12 @@
.hasMessageThat()
.isEqualTo(
String.format(
- "Change %s could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n%s",
+ """
+ Change %s could not be rebased due to a conflict during merge.
+
+ merge conflict(s):
+ * %s
+ """,
r2.getChange().getId(), PushOneCommit.FILE_NAME));
}
@@ -1690,14 +1773,14 @@
"add merge=union to gitattributes",
".gitattributes",
"*.txt merge=union");
- PushOneCommit.Result unusedResult = pushAttributes.to("refs/heads/master");
+ PushOneCommit.Result r1 = pushAttributes.to("refs/heads/master");
- PushOneCommit.Result r1 = createChange();
+ PushOneCommit.Result r2 = createChange();
gApi.changes()
- .id(r1.getChangeId())
- .revision(r1.getCommit().name())
+ .id(r2.getChangeId())
+ .revision(r2.getCommit().name())
.review(ReviewInput.approve());
- gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+ gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).submit();
PushOneCommit push =
pushFactory.create(
@@ -1707,11 +1790,11 @@
PushOneCommit.FILE_NAME,
"other content",
"I3bf2c82554e83abc759154e85db94c7ebb079c70");
- PushOneCommit.Result r2 = push.to("refs/for/master");
- r2.assertOkStatus();
- String changeId = r2.getChangeId();
- RevCommit patchSet = r2.getCommit();
- RevCommit base = r1.getCommit();
+ PushOneCommit.Result r3 = push.to("refs/for/master");
+ r3.assertOkStatus();
+ String changeId = r3.getChangeId();
+ RevCommit patchSet = r3.getCommit();
+ RevCommit base = r2.getCommit();
RebaseInput rebaseInput = new RebaseInput();
rebaseInput.strategy = "recursive";
ChangeInfo changeInfo =
@@ -1722,8 +1805,11 @@
RevisionInfo currentRevision =
gApi.changes().id(changeId).get(CURRENT_REVISION).getCurrentRevision();
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(r1.getCommit().name());
assertThat(currentRevision.conflicts.ours).isEqualTo(patchSet.name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(base.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
// Verify that the file content in the created patch set is correct.
@@ -2008,9 +2094,13 @@
// *r5
PushOneCommit.Result r = createChange();
testRepo.reset("HEAD~1");
+ RevCommit head = projectOperations.project(project).getHead("master");
PushOneCommit.Result r2 = createChange();
+ String r2PatchSet1 = getCurrentRevision(r2.getChange().getId());
PushOneCommit.Result r3 = createChange();
+ String r3PatchSet1 = getCurrentRevision(r3.getChange().getId());
PushOneCommit.Result r4 = createChange();
+ String r4PatchSet1 = getCurrentRevision(r4.getChange().getId());
PushOneCommit.Result r5 = createChange();
// Approve and submit the first change
@@ -2028,11 +2118,27 @@
// Only r2, r3 and r4 are rebased.
verifyRebaseForChange(
- r2.getChange().getId(), r2.getCommit().name(), r.getCommit().name(), true, 2);
+ r2.getChange().getId(),
+ head,
+ r2.getCommit().name(),
+ r.getCommit().name(),
+ "recursive",
+ true,
+ 2);
verifyRebaseForChange(
- r3.getChange().getId(), r3.getCommit().name(), r2.getChange().getId(), true);
+ r3.getChange().getId(),
+ ObjectId.fromString(r2PatchSet1),
+ r3.getCommit().name(),
+ r2.getChange().getId(),
+ "recursive",
+ true);
verifyRebaseForChange(
- r4.getChange().getId(), r4.getCommit().name(), r3.getChange().getId(), false);
+ r4.getChange().getId(),
+ ObjectId.fromString(r3PatchSet1),
+ r4.getCommit().name(),
+ r3.getChange().getId(),
+ "recursive",
+ false);
verifyChangeIsUpToDate(r2);
verifyChangeIsUpToDate(r3);
@@ -2052,7 +2158,12 @@
gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5);
verifyRebaseForChange(
- r5.getChange().getId(), r5.getCommit().name(), r4.getChange().getId(), false);
+ r5.getChange().getId(),
+ ObjectId.fromString(r4PatchSet1),
+ r5.getCommit().name(),
+ r4.getChange().getId(),
+ "recursive",
+ false);
}
@Test
@@ -2100,6 +2211,8 @@
.createV1();
approveAndSubmit(changeInOtherBranch);
+ RevCommit head = projectOperations.project(project).getHead("master");
+
// Create a merge change with a conflict resolution.
Change.Id mergeChangeId =
changeOperations
@@ -2176,24 +2289,30 @@
verifyRebaseForChange(
mergeChangeId,
+ head,
mergeCommitThatIsBeingRebased,
ImmutableList.of(
getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)),
+ "recursive",
/* shouldHaveApproval= */ false,
/* shouldHaveConflicts,= */ false,
/* expectedNumRevisions= */ 2);
verifyRebaseForChange(
followUpChangeId,
+ ObjectId.fromString(mergeCommitThatIsBeingRebased),
followUpCommitThatIsBeingRebased,
ImmutableList.of(getCurrentRevision(mergeChangeId)),
+ "recursive",
/* shouldHaveApproval= */ false,
/* shouldHaveConflicts,= */ false,
/* expectedNumRevisions= */ 2);
verifyRebaseForChange(
followUpMergeChangeId,
+ ObjectId.fromString(followUpCommitThatIsBeingRebased),
followUpMergeCommitThatIsBeingRebased,
ImmutableList.of(
getCurrentRevision(followUpChangeId), getCurrentRevision(anotherChangeInOtherBranch)),
+ "recursive",
/* shouldHaveApproval= */ false,
/* shouldHaveConflicts,= */ false,
/* expectedNumRevisions= */ 2);
@@ -2223,8 +2342,11 @@
// * r4
PushOneCommit.Result r = createChange();
testRepo.reset("HEAD~1");
+ RevCommit head = projectOperations.project(project).getHead("master");
PushOneCommit.Result r2 = createChange();
+ String r2PatchSet1 = getCurrentRevision(r2.getChange().getId());
PushOneCommit.Result r3 = createChange("original patch-set", file, oldContent);
+ String r3PatchSet1 = getCurrentRevision(r3.getChange().getId());
PushOneCommit.Result r4 = createChange();
gApi.changes()
.id(r3.getChangeId())
@@ -2242,10 +2364,28 @@
rebaseCall.call(r4.getChangeId());
verifyRebaseForChange(
- r2.getChange().getId(), r2.getCommit().name(), r.getCommit().name(), false, 2);
- verifyRebaseForChange(r3.getChange().getId(), r3PatchSet2, r2.getChange().getId(), false, 3);
+ r2.getChange().getId(),
+ head,
+ r2.getCommit().name(),
+ r.getCommit().name(),
+ "recursive",
+ false,
+ 2);
verifyRebaseForChange(
- r4.getChange().getId(), r4.getCommit().name(), r3.getChange().getId(), false);
+ r3.getChange().getId(),
+ ObjectId.fromString(r2PatchSet1),
+ r3PatchSet2,
+ r2.getChange().getId(),
+ "recursive",
+ false,
+ 3);
+ verifyRebaseForChange(
+ r4.getChange().getId(),
+ ObjectId.fromString(r3PatchSet1),
+ r4.getCommit().name(),
+ r3.getChange().getId(),
+ "recursive",
+ false);
assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString())
.isEqualTo(newContent);
@@ -2269,8 +2409,11 @@
// *r5
PushOneCommit.Result r = createChange();
PushOneCommit.Result r2 = createChange();
+ String r2PatchSet1 = getCurrentRevision(r2.getChange().getId());
PushOneCommit.Result r3 = createChange();
+ String r3PatchSet1 = getCurrentRevision(r3.getChange().getId());
PushOneCommit.Result r4 = createChange();
+ String r4PatchSet1 = getCurrentRevision(r4.getChange().getId());
PushOneCommit.Result r5 = createChange();
// Approve and submit the first change
@@ -2298,9 +2441,19 @@
// Only r3 and r4 are rebased.
verifyRebaseForChange(
- r3.getChange().getId(), r3.getCommit().name(), r2.getChange().getId(), true);
+ r3.getChange().getId(),
+ ObjectId.fromString(r2PatchSet1),
+ r3.getCommit().name(),
+ r2.getChange().getId(),
+ "recursive",
+ true);
verifyRebaseForChange(
- r4.getChange().getId(), r4.getCommit().name(), r3.getChange().getId(), false);
+ r4.getChange().getId(),
+ ObjectId.fromString(r3PatchSet1),
+ r4.getCommit().name(),
+ r3.getChange().getId(),
+ "recursive",
+ false);
verifyChangeIsUpToDate(r2);
verifyChangeIsUpToDate(r3);
@@ -2320,7 +2473,12 @@
gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r3, r4, r5);
verifyRebaseForChange(
- r5.getChange().getId(), r5.getCommit().name(), r4.getChange().getId(), false);
+ r5.getChange().getId(),
+ ObjectId.fromString(r4PatchSet1),
+ r5.getCommit().name(),
+ r4.getChange().getId(),
+ "recursive",
+ false);
}
@Test
@@ -2352,8 +2510,12 @@
.hasMessageThat()
.isEqualTo(
String.format(
- "Change %s could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n%s",
+ """
+ Change %s could not be rebased due to a conflict during merge.
+
+ merge conflict(s):
+ * %s
+ """,
r2.getChange().getId(), PushOneCommit.FILE_NAME));
}
@@ -2374,6 +2536,8 @@
String baseSubject = "base change";
String baseContent = "base content";
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
gApi.changes()
.id(r1.getChangeId())
@@ -2418,8 +2582,11 @@
RevisionInfo parentChangeCurrentRevision = parentChangeInfo.getCurrentRevision();
assertThat(parentChangeCurrentRevision.commit.parents.get(0).commit).isEqualTo(base.name());
assertThat(parentChangeCurrentRevision.conflicts).isNotNull();
+ assertThat(parentChangeCurrentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(parentChangeCurrentRevision.conflicts.ours).isEqualTo(parentPatchSet.name());
assertThat(parentChangeCurrentRevision.conflicts.theirs).isEqualTo(base.name());
+ assertThat(parentChangeCurrentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(parentChangeCurrentRevision.conflicts.noBaseReason).isNull();
assertThat(parentChangeCurrentRevision.conflicts.containsConflicts).isTrue();
ChangeInfo childChangeInfo = rebaseChainInfo.rebasedChanges.get(1);
@@ -2431,9 +2598,12 @@
assertThat(childChangeCurrentRevision.commit.parents.get(0).commit)
.isEqualTo(parentChangeCurrentRevision.commit.commit);
assertThat(childChangeCurrentRevision.conflicts).isNotNull();
+ assertThat(childChangeCurrentRevision.conflicts.base).isEqualTo(parentPatchSet.name());
assertThat(childChangeCurrentRevision.conflicts.ours).isEqualTo(childPatchSet.name());
assertThat(childChangeCurrentRevision.conflicts.theirs)
.isEqualTo(parentChangeCurrentRevision.commit.commit);
+ assertThat(childChangeCurrentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(childChangeCurrentRevision.conflicts.noBaseReason).isNull();
assertThat(childChangeCurrentRevision.conflicts.containsConflicts).isTrue();
}
assertThat(wipStateChangedListener.invoked).isTrue();
@@ -2464,7 +2634,11 @@
+ ")\n"
+ patchSetContent
+ "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ initialHead.getName(), initialHead.getShortMessage())
+ : "")
+ "=======\n"
+ baseContent
+ "\n"
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 6d8cc3d..ef23acd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -47,6 +47,7 @@
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.extensions.common.PureRevertInfo;
import com.google.gerrit.extensions.common.RevertSubmissionInfo;
import com.google.gerrit.extensions.restapi.AuthException;
@@ -208,8 +209,12 @@
assertThat(revertChange.getCurrentRevision().conflicts).isNotNull();
assertThat(revertChange.getCurrentRevision().conflicts.containsConflicts).isFalse();
+ assertThat(revertChange.getCurrentRevision().conflicts.base).isNull();
assertThat(revertChange.getCurrentRevision().conflicts.ours).isNull();
assertThat(revertChange.getCurrentRevision().conflicts.theirs).isNull();
+ assertThat(revertChange.getCurrentRevision().conflicts.mergeStrategy).isNull();
+ assertThat(revertChange.getCurrentRevision().conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.NO_MERGE_PERFORMED);
}
@Test
@@ -1016,8 +1021,12 @@
assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts).isNotNull();
assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.containsConflicts)
.isFalse();
+ assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.base).isNull();
assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.ours).isNull();
assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.theirs).isNull();
+ assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.mergeStrategy).isNull();
+ assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.NO_MERGE_PERFORMED);
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 868cc5e..9e56a9f 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -26,7 +26,6 @@
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.HEAD;
import static org.eclipse.jgit.lib.Constants.R_TAGS;
import com.google.common.collect.ImmutableMap;
@@ -178,6 +177,8 @@
@Test
public void cherryPickWithoutConflicts() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
String destBranch = "foo";
createBranch(BranchNameKey.create(project, destBranch));
@@ -204,8 +205,11 @@
.getCurrentRevision();
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.getName());
assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName());
assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
}
@@ -326,7 +330,7 @@
}
private void testCherryPickWithAllowConflicts(boolean useDiff3) throws Exception {
- ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
// Create a branch and push a commit to it (by-passing review)
String destBranch = "foo";
@@ -341,7 +345,7 @@
push.to("refs/heads/" + destBranch);
// Create a change on master with a commit that conflicts with the commit on the other branch.
- testRepo.reset(initial);
+ testRepo.reset(initialHead);
String changeContent = "another content";
push =
pushFactory.create(
@@ -392,8 +396,11 @@
.getCurrentRevision();
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.getName());
assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName());
assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
// Verify that the file content in the cherry-pick change is correct.
@@ -414,7 +421,11 @@
+ " test commit)\n"
+ destContent
+ "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ initialHead.getName(), initialHead.getShortMessage())
+ : "")
+ "=======\n"
+ changeContent
+ "\n"
@@ -435,7 +446,7 @@
}
private void testCherryPickToExistingChangeWithAllowConflicts(boolean useDiff3) throws Exception {
- String tip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
String destBranch = "foo";
createBranch(BranchNameKey.create(project, destBranch));
@@ -443,7 +454,7 @@
PushOneCommit.Result existingChange =
createChange(testRepo, destBranch, SUBJECT, FILE_NAME, destContent, null);
- testRepo.reset(tip);
+ testRepo.reset(initialHead);
String changeContent = "another content";
PushOneCommit.Result srcChange =
createChange(testRepo, "master", SUBJECT, FILE_NAME, changeContent, null);
@@ -492,8 +503,11 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(existingChange.getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.name());
assertThat(currentRevision.conflicts.ours).isEqualTo(existingChange.getCommit().name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(srcChange.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
// Verify that the file content in the cherry-pick change is correct.
@@ -514,7 +528,11 @@
+ " test commit)\n"
+ destContent
+ "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ initialHead.getName(), initialHead.getShortMessage())
+ : "")
+ "=======\n"
+ changeContent
+ "\n"
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 80ac91b..eebcbb8 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -333,6 +333,8 @@
@Test
public void cherryPick() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+
PushOneCommit.Result r = pushTo("refs/for/master%topic=someTopic");
CherryPickInput in = new CherryPickInput();
in.destination = "foo";
@@ -353,8 +355,11 @@
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name());
assertThat(currentRevision.conflicts).isNotNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.getName());
assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName());
assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
ChangeInfo cherryPickChangeInfoWithDetails = cherry.get();
assertThat(cherryPickChangeInfoWithDetails.workInProgress).isNull();
@@ -761,7 +766,7 @@
}
private void testCherryPickConflictWithAllowConflicts(boolean useDiff3) throws Exception {
- ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
// Create a branch and push a commit to it (by-passing review)
String destBranch = "foo";
@@ -776,7 +781,7 @@
push.to("refs/heads/" + destBranch);
// Create a change on master with a commit that conflicts with the commit on the other branch.
- testRepo.reset(initial);
+ testRepo.reset(initialHead);
String changeContent = "another content";
push =
pushFactory.create(
@@ -816,8 +821,11 @@
assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name());
assertThat(currentRevision.conflicts).isNotNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+ assertThat(currentRevision.conflicts.base).isEqualTo(initialHead.getName());
assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName());
assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
// Verify that subject and topic on the cherry-pick change have been correctly populated.
assertThat(cherryPickChange.subject).contains(in.message);
@@ -841,7 +849,11 @@
+ " test commit)\n"
+ destContent
+ "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ initialHead.getName(), initialHead.getShortMessage())
+ : "")
+ "=======\n"
+ changeContent
+ "\n"
@@ -895,8 +907,11 @@
.isEqualTo(existingChange.getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+ assertThat(currentRevision.conflicts.base).isEqualTo(tip);
assertThat(currentRevision.conflicts.ours).isEqualTo(existingChange.getCommit().name());
assertThat(currentRevision.conflicts.theirs).isEqualTo(srcChange.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 8a04811..24ff37d 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -302,9 +302,14 @@
.hasMessageThat()
.isEqualTo(
String.format(
- "Rebasing change edit onto another patchset results in merge conflicts.\n\n"
- + "merge conflict(s):\n%s\n\n"
- + "Download the edit patchset and rebase manually to preserve changes.",
+ """
+ Rebasing change edit onto another patchset results in merge conflicts.
+
+ merge conflict(s):
+ * %s
+
+ Download the edit patchset and rebase manually to preserve changes.
+ """,
FILE_NAME));
}
@@ -324,6 +329,7 @@
String changeId = newChange(admin.newIdent());
PatchSet previousPatchSet = getCurrentPatchSet(changeId);
+ String subjectPreviousPatchSet = gApi.changes().id(changeId).get().subject;
createEmptyEditFor(changeId);
gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
@@ -345,7 +351,13 @@
String.format(
"<<<<<<< PATCH SET (%s %s)\n"
+ "%s\n"
- + (useDiff3 ? String.format("||||||| BASE\n%s\n", CONTENT_OLD_STR) : "")
+ + (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n%s\n",
+ previousPatchSet.commitId().name(),
+ subjectPreviousPatchSet,
+ CONTENT_OLD_STR)
+ : "")
+ "=======\n"
+ "%s\n"
+ ">>>>>>> EDIT (%s %s)\n",
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
index 3b158a9..25d25c7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.git;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static org.eclipse.jgit.lib.Constants.HEAD;
@@ -23,19 +24,28 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GitUtil;
import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.extensions.common.MergeInput;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
import org.junit.Before;
import org.junit.Test;
/** Ensures that auto merge commits are created when a new patch set or change is uploaded. */
public class AutoMergeIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
+
private RevCommit parent1;
private RevCommit parent2;
@@ -100,6 +110,187 @@
}
@Test
+ public void autoMergeCreatedWhenPushingMergeBetweenTwoInitialCommits() throws Exception {
+ Project.NameKey projectWithoutInitialCommit =
+ projectOperations.newProject().createEmptyCommit(false).create();
+
+ TestRepository<InMemoryRepository> testRepo =
+ cloneProject(projectWithoutInitialCommit, getCloneAsAccount(configRule.description()));
+
+ RevCommit initialCommit1 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message("Initial Change 1")
+ .insertChangeId()
+ .add("file1", "contents1")
+ .create());
+ RevCommit initialCommit2 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message("Initial Change 2")
+ .insertChangeId()
+ .add("file1", "contents2")
+ .create());
+ RevCommit mergeCommit =
+ testRepo
+ .branch("master")
+ .commit()
+ .message("Merge Change")
+ .parent(initialCommit1)
+ .parent(initialCommit2)
+ .insertChangeId()
+ .create();
+ testRepo.reset(mergeCommit);
+ PushResult r = pushHead(testRepo, "refs/for/master");
+ assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isEqualTo(Status.OK);
+
+ assertAutoMergeCreated(projectWithoutInitialCommit, mergeCommit);
+ }
+
+ @Test
+ public void autoMergeCreatedWhenPushingCrissCrossMerge() throws Exception {
+ RevCommit initialCommit = repo().parseCommit(repo().exactRef(HEAD).getLeaf().getObjectId());
+ RevCommit baseCommit1 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .parent(initialCommit)
+ .message("Change 1")
+ .insertChangeId()
+ .add("file1", "contents1")
+ .create());
+ RevCommit baseCommit2 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .parent(initialCommit)
+ .message("Change 2")
+ .insertChangeId()
+ .add("file2", "contents2")
+ .create());
+ RevCommit mergeCommit1 =
+ testRepo
+ .commit()
+ .message("Merge Change In Source")
+ .parent(baseCommit1)
+ .parent(baseCommit2)
+ .insertChangeId()
+ .create();
+ RevCommit mergeCommit2 =
+ testRepo
+ .commit()
+ .message("Merge Change In Target")
+ .parent(baseCommit2)
+ .parent(baseCommit1)
+ .insertChangeId()
+ .create();
+ RevCommit conflictingCommit1 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message("Change 1")
+ .parent(mergeCommit1)
+ .insertChangeId()
+ .add("conflicting-file", "contents1")
+ .create());
+ RevCommit conflictingCommit2 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message("Change 2")
+ .parent(mergeCommit2)
+ .insertChangeId()
+ .add("conflicting-file", "contents2")
+ .create());
+ RevCommit crissCrossMerge =
+ testRepo
+ .commit()
+ .message("Criss-Cross Merge")
+ .parent(conflictingCommit1)
+ .parent(conflictingCommit2)
+ .insertChangeId()
+ .create();
+ testRepo.reset(crissCrossMerge);
+ PushResult r = pushHead(testRepo, "refs/for/master");
+ assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isEqualTo(Status.OK);
+ assertAutoMergeCreated(crissCrossMerge);
+ }
+
+ @Test
+ public void autoMergeCreatedWhenPushingCrissCrossMergeWithConflictingBases() throws Exception {
+ RevCommit initialCommit = repo().parseCommit(repo().exactRef(HEAD).getLeaf().getObjectId());
+ String baseFile = "baseFile,txt";
+ RevCommit baseCommit1 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .parent(initialCommit)
+ .message("Change 1")
+ .insertChangeId()
+ .add(baseFile, "contents1")
+ .create());
+ RevCommit baseCommit2 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .parent(initialCommit)
+ .message("Change 2")
+ .insertChangeId()
+ .add(baseFile, "contents2")
+ .create());
+ RevCommit mergeCommit1 =
+ testRepo
+ .commit()
+ .message("Merge Change In Source")
+ .add(baseFile, "contents1")
+ .parent(baseCommit1)
+ .parent(baseCommit2)
+ .insertChangeId()
+ .create();
+ RevCommit mergeCommit2 =
+ testRepo
+ .commit()
+ .message("Merge Change In Target")
+ .add(baseFile, "contents2")
+ .parent(baseCommit2)
+ .parent(baseCommit1)
+ .insertChangeId()
+ .create();
+ RevCommit conflictingCommit1 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message("Change 1")
+ .parent(mergeCommit1)
+ .insertChangeId()
+ .add("conflicting-file", "contents1")
+ .create());
+ RevCommit conflictingCommit2 =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message("Change 2")
+ .parent(mergeCommit2)
+ .insertChangeId()
+ .add("conflicting-file", "contents2")
+ .create());
+ RevCommit crissCrossMerge =
+ testRepo
+ .commit()
+ .message("Criss-Cross Merge")
+ .parent(conflictingCommit1)
+ .parent(conflictingCommit2)
+ .insertChangeId()
+ .create();
+ testRepo.reset(crissCrossMerge);
+ PushResult r = pushHead(testRepo, "refs/for/master");
+ assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isEqualTo(Status.OK);
+ assertAutoMergeCreated(crissCrossMerge);
+ }
+
+ @Test
public void autoMergeCreatedWhenChangeCreatedOnApi() throws Exception {
ChangeInput ci = new ChangeInput(project.get(), "master", "Merge commit");
ci.merge = new MergeInput();
@@ -187,6 +378,11 @@
}
private void assertAutoMergeCreated(ObjectId mergeCommit) throws Exception {
+ assertAutoMergeCreated(project, mergeCommit);
+ }
+
+ private void assertAutoMergeCreated(Project.NameKey project, ObjectId mergeCommit)
+ throws Exception {
try (Repository repo = repoManager.openRepository(project)) {
assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNotNull();
}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 9fab01b..4e24a94 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -10,7 +10,7 @@
"pgm",
"no_windows",
],
- vm_args = ["-Xmx512m"],
+ vm_args = ["-Xmx1g"],
deps = [
":util",
"//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index 98228be..b79977e 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -21,11 +21,13 @@
import com.google.errorprone.annotations.MustBeClosed;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Project;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.QueryOptions;
import com.google.gerrit.index.project.ProjectData;
import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.account.AuthTokenCache;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
import java.io.IOException;
@@ -82,8 +84,27 @@
assertThat(projectsLastModified).isEqualTo(projectsLastModifiedAfterInit);
}
- private void initSite() throws Exception {
- runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+ @Test
+ public void initInDevModeCreatesAdminUserWithToken() throws Exception {
+ initSite("--dev");
+
+ try (ServerContext ctx = startServer()) {
+ AuthTokenCache tokenCache = ctx.getInjector().getInstance(AuthTokenCache.class);
+ assertThat(tokenCache.get(Account.id(10000))).isNotNull();
+ }
+ }
+
+ private void initSite(String... additionalOptions) throws Exception {
+ if (additionalOptions.length > 0) {
+ runGerrit(
+ "init",
+ "-d",
+ sitePaths.site_path.toString(),
+ "--show-stack-trace",
+ String.join(" ", additionalOptions));
+ } else {
+ runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+ }
}
private void setProjectsIndexLastModifiedInThePast(Path indexDir, Instant time)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigratePasswordsToTokensIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigratePasswordsToTokensIT.java
new file mode 100644
index 0000000..9b4c146
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigratePasswordsToTokensIT.java
@@ -0,0 +1,96 @@
+// 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.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.server.account.PasswordMigrator;
+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.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class MigratePasswordsToTokensIT extends StandaloneSiteTest {
+
+ @Test
+ public void httpPasswordIsBeingMigratedToToken() throws Exception {
+ initSite();
+
+ String username = "foo";
+ String httpPassword = "secret";
+ int accountId;
+
+ try (ServerContext ctx = startServer()) {
+ GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+ accountId = gApi.accounts().create(username).detail()._accountId;
+ Project.NameKey allUsers = ctx.getInjector().getInstance(AllUsersName.class);
+ ExternalIdNotes extIdNotes = getExternalIdNotes(ctx, allUsers);
+ ExternalIdFactory extIdFactory = ctx.getInjector().getInstance(ExternalIdFactory.class);
+ MetaDataUpdate md = getMetaDataUpdate(ctx, allUsers);
+ extIdNotes.upsert(
+ extIdFactory.create(
+ SCHEME_USERNAME, username, Account.id(accountId), null, httpPassword));
+ extIdNotes.commit(md);
+ }
+
+ runGerrit("MigratePasswordsToTokens", "-d", sitePaths.site_path.toString());
+
+ try (ServerContext ctx = startServer()) {
+ GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+ List<AuthTokenInfo> actual = gApi.accounts().id(accountId).getTokens();
+ assertThat(actual.size()).isEqualTo(1);
+ assertThat(actual.get(0).id).isEqualTo(PasswordMigrator.DEFAULT_ID);
+ assertThat(actual.get(0).token).isNull();
+
+ Project.NameKey allUsers = ctx.getInjector().getInstance(AllUsersName.class);
+ ExternalIdNotes extIdNotes = getExternalIdNotes(ctx, allUsers);
+ ExternalIdKeyFactory extIdKeyFactory =
+ ctx.getInjector().getInstance(ExternalIdKeyFactory.class);
+ assertThat(extIdNotes.get(extIdKeyFactory.create(SCHEME_USERNAME, username)).get().password())
+ .isNull();
+ }
+ }
+
+ private void initSite() throws Exception {
+ runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+ }
+
+ private static ExternalIdNotes getExternalIdNotes(ServerContext ctx, Project.NameKey allUsers)
+ throws Exception {
+ GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
+ ExternalIdNotes.FactoryNoReindex extIdNotesFactory =
+ ctx.getInjector().getInstance(ExternalIdNotes.FactoryNoReindex.class);
+ return extIdNotesFactory.load(repoManager.openRepository(allUsers));
+ }
+
+ private static MetaDataUpdate getMetaDataUpdate(ServerContext ctx, Project.NameKey allUsers)
+ throws Exception {
+ MetaDataUpdate.Server metaDataUpdateFactory =
+ ctx.getInjector().getInstance(MetaDataUpdate.Server.class);
+ return metaDataUpdateFactory.create(allUsers);
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReduceMaxTokenLifetimeIT.java b/javatests/com/google/gerrit/acceptance/pgm/ReduceMaxTokenLifetimeIT.java
new file mode 100644
index 0000000..af0ece7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/ReduceMaxTokenLifetimeIT.java
@@ -0,0 +1,103 @@
+// 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.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class ReduceMaxTokenLifetimeIT extends StandaloneSiteTest {
+ private static final String USERNAME = "foo";
+ private static final String TOKEN_ID = "id";
+
+ @Test
+ public void tokenWithNoLifetimeGetsLifetime() throws Exception {
+ initSite();
+ Account.Id accountId = createAccountWithToken(null);
+
+ runGerrit("ReduceMaxTokenLifetime", "-d", sitePaths.site_path.toString(), "--lifetime", "1d");
+
+ try (ServerContext ctx = startServer()) {
+ GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+ List<AuthTokenInfo> actual = gApi.accounts().id(accountId.get()).getTokens();
+ assertThat(actual.get(0).id).isEqualTo(TOKEN_ID);
+ assertThat(actual.get(0).expiration).isNotNull();
+ }
+ }
+
+ @Test
+ public void tokenWithTooLongLifetimeGetsAdaptedLifetime() throws Exception {
+ initSite();
+ Account.Id accountId = createAccountWithToken("2d");
+
+ runGerrit("ReduceMaxTokenLifetime", "-d", sitePaths.site_path.toString(), "--lifetime", "1d");
+
+ try (ServerContext ctx = startServer()) {
+ GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+ List<AuthTokenInfo> actual = gApi.accounts().id(accountId.get()).getTokens();
+ assertThat(actual.get(0).id).isEqualTo(TOKEN_ID);
+ assertThat(actual.get(0).expiration)
+ .isLessThan(Timestamp.from(Instant.now().plus(1, ChronoUnit.DAYS)));
+ }
+ }
+
+ @Test
+ public void tokenWithValidLifetimeDontGetAdapted() throws Exception {
+ initSite();
+ Account.Id accountId = createAccountWithToken("2h");
+
+ runGerrit("ReduceMaxTokenLifetime", "-d", sitePaths.site_path.toString(), "--lifetime", "1d");
+
+ try (ServerContext ctx = startServer()) {
+ GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+ List<AuthTokenInfo> actual = gApi.accounts().id(accountId.get()).getTokens();
+ assertThat(actual.get(0).id).isEqualTo(TOKEN_ID);
+ assertThat(actual.get(0).expiration)
+ .isLessThan(Timestamp.from(Instant.now().plus(2, ChronoUnit.HOURS)));
+ }
+ }
+
+ private void initSite() throws Exception {
+ runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+ }
+
+ private Account.Id createAccountWithToken(@Nullable String tokenLifetime) throws Exception {
+ Account.Id accountId;
+ try (ServerContext ctx = startServer()) {
+ GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+ accountId = Account.id(gApi.accounts().create(USERNAME).detail()._accountId);
+
+ AuthTokenInput input = new AuthTokenInput();
+ input.id = TOKEN_ID;
+ if (tokenLifetime != null) {
+ input.lifetime = tokenLifetime;
+ }
+ gApi.accounts().id(accountId.get()).createToken(input);
+ }
+ return accountId;
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
index 5fef74c..36ce553 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
@@ -19,10 +19,19 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import java.util.List;
import org.junit.Test;
public class CreateAccountIT extends AbstractDaemonTest {
+ @Inject AuthTokenAccessor tokenAccessor;
+
@Test
public void createAccountRestApi() throws Exception {
AccountInput input = new AccountInput();
@@ -34,6 +43,22 @@
}
@Test
+ public void createAccountWithTokenAsAdminSucceeds() throws Exception {
+ AccountInput input = new AccountInput();
+ input.username = "foo";
+ AuthTokenInput token = new AuthTokenInput();
+ token.id = "test";
+ token.token = "secret";
+ input.tokens = List.of(token);
+ RestResponse r = adminRestSession.put("/accounts/" + input.username, input);
+ r.assertCreated();
+
+ JsonObject json = JsonParser.parseReader(r.getReader()).getAsJsonObject();
+ assertThat(tokenAccessor.getToken(Account.id(json.get("_account_id").getAsInt()), token.id))
+ .isNotNull();
+ }
+
+ @Test
@GerritConfig(name = "auth.userNameToLowerCase", value = "false")
public void createAccountRestApiUserNameToLowerCaseFalse() throws Exception {
AccountInput input = new AccountInput();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index e62d365..d9ba200 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -474,11 +474,6 @@
createExternalIdWithDuplicateEmail("foo:bar"));
}
- @Test
- public void pushToExternalIdsBranchRejectsBadPassword() throws Exception {
- testPushToExternalIdsBranchRejectsInvalidExternalId(createExternalIdWithBadPassword("foo"));
- }
-
private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
throws Exception {
projectOperations
@@ -566,11 +561,8 @@
// create valid external IDs
insertExtId(
- externalIdFactory.createWithPassword(
- externalIdKeyFactory.parse(nextId(scheme, i)),
- admin.id(),
- "admin.other@example.com",
- "secret-password"));
+ externalIdFactory.createWithEmail(
+ externalIdKeyFactory.parse(nextId(scheme, i)), admin.id(), "admin.other@example.com"));
insertExtId(externalIdFactory.createEmail(admin.id(), "admin.other@example.com"));
insertExtId(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
}
@@ -611,14 +603,6 @@
+ extIdWithDuplicateEmail.email()
+ "'"));
- ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
- insertExtId(extIdWithBadPassword);
- expectedProblems.add(
- consistencyError(
- "External ID '"
- + extIdWithBadPassword.key().get()
- + "' has an invalid password: unrecognized algorithm"));
-
return expectedProblems;
}
@@ -692,14 +676,6 @@
externalIdKeyFactory.parse(externalId), user.id(), admin.email());
}
- private ExternalId createExternalIdWithBadPassword(String username) {
- return externalIdFactory.create(
- externalIdKeyFactory.create(SCHEME_USERNAME, username),
- admin.id(),
- null,
- "non-hashed-password-is-not-allowed");
- }
-
private static String nextId(String scheme, MutableInteger i) {
return scheme + ":foo" + ++i.value;
}
@@ -764,6 +740,7 @@
}
@Test
+ @Deprecated
public void unsetHttpPassword() throws Exception {
ExternalId extId =
externalIdFactory.createWithPassword(
@@ -805,8 +782,7 @@
// update the first external ID
ExternalId updatedExtId1 =
- externalIdFactory.create(
- extId1.key(), accountId, "foo.bar@example.com", /* hashedPassword= */ null);
+ externalIdFactory.createWithEmail(extId1.key(), accountId, "foo.bar@example.com");
accountsUpdateProvider
.get()
.update("Update External ID", accountId, u -> u.updateExternalId(updatedExtId1));
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/HttpPasswordIT.java b/javatests/com/google/gerrit/acceptance/rest/account/HttpPasswordIT.java
new file mode 100644
index 0000000..db1b85e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/HttpPasswordIT.java
@@ -0,0 +1,126 @@
+// 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.common.HttpPasswordInput;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.account.PasswordMigrator;
+import com.google.inject.Inject;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.junit.Before;
+import org.junit.Test;
+
+public class HttpPasswordIT extends AbstractDaemonTest {
+ @Inject AuthTokenAccessor tokenAccessor;
+
+ TestAccount testUser;
+ RestSession restSession;
+
+ @Before
+ public void setUp() throws Exception {
+ testUser = accountCreator.create(RandomStringUtils.randomAlphabetic(10));
+ restSession = server.createRestSession(testUser);
+ }
+
+ @Test
+ public void generateOwnPasswordSucceeds() throws Exception {
+ HttpPasswordInput passwordInput = new HttpPasswordInput();
+ passwordInput.generate = true;
+ RestResponse resp = restSession.put("/accounts/self/password.http", passwordInput);
+ resp.assertCreated();
+
+ String returnedToken =
+ Iterables.get(Splitter.onPattern(System.lineSeparator()).split(resp.getEntityContent()), 1);
+ assertThat(returnedToken.substring(1, returnedToken.length() - 1)).isNotEmpty();
+
+ assertThat(tokenAccessor.getToken(testUser.id(), PasswordMigrator.DEFAULT_ID)).isPresent();
+ }
+
+ @Test
+ public void createPasswordForOtherUserFailsForNonAdmins() throws Exception {
+ HttpPasswordInput passwordInput = new HttpPasswordInput();
+ passwordInput.generate = true;
+ restSession
+ .put(String.format("/accounts/%d/password.http", admin.id().get()), passwordInput)
+ .assertForbidden();
+ }
+
+ @Test
+ public void createPasswordForOtherUserSucceedsForAdmins() throws Exception {
+ HttpPasswordInput passwordInput = new HttpPasswordInput();
+ passwordInput.generate = true;
+ adminRestSession
+ .put(String.format("/accounts/%d/password.http", testUser.id().get()), passwordInput)
+ .assertCreated();
+ }
+
+ @Test
+ public void setSpecificTokenFailsForNonAdmins() throws Exception {
+ HttpPasswordInput passwordInput = new HttpPasswordInput();
+ passwordInput.httpPassword = "secret";
+ restSession
+ .put(String.format("/accounts/%d/password.http", testUser.id().get()), passwordInput)
+ .assertForbidden();
+ }
+
+ @Test
+ public void setSpecificTokenSucceedsForAdmins() throws Exception {
+ HttpPasswordInput passwordInput = new HttpPasswordInput();
+ passwordInput.httpPassword = "secret";
+ RestResponse resp =
+ adminRestSession.put(
+ String.format("/accounts/%d/password.http", testUser.id().get()), passwordInput);
+
+ resp.assertCreated();
+
+ String returnedToken =
+ Iterables.get(Splitter.onPattern(System.lineSeparator()).split(resp.getEntityContent()), 1);
+ assertThat(returnedToken.substring(1, returnedToken.length() - 1))
+ .isEqualTo(passwordInput.httpPassword);
+
+ assertThat(tokenAccessor.getToken(testUser.id(), PasswordMigrator.DEFAULT_ID)).isPresent();
+ }
+
+ @Test
+ public void deletePasswordSucceeds() throws Exception {
+ restSession
+ .delete(String.format("/accounts/%d/password.http", testUser.id().get()))
+ .assertNoContent();
+ assertThat(tokenAccessor.getToken(testUser.id(), PasswordMigrator.DEFAULT_ID)).isEmpty();
+ }
+
+ @Test
+ public void deleteTokenForOtherUserSucceedsForAdmins() throws Exception {
+ adminRestSession
+ .delete(String.format("/accounts/%d/password.http", testUser.id().get()))
+ .assertNoContent();
+ assertThat(tokenAccessor.getToken(testUser.id(), PasswordMigrator.DEFAULT_ID)).isEmpty();
+ }
+
+ @Test
+ public void deleteTokenForOtherUserFailsForNonAdmins() throws Exception {
+ restSession
+ .delete(String.format("/accounts/%d/tokens/adminToken1", admin.id().get()))
+ .assertForbidden();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/TokenIT.java b/javatests/com/google/gerrit/acceptance/rest/account/TokenIT.java
new file mode 100644
index 0000000..d088e77
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/TokenIT.java
@@ -0,0 +1,223 @@
+// 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TokenIT extends AbstractDaemonTest {
+ @Inject AuthTokenAccessor tokenAccessor;
+
+ private AuthTokenInput authTokenInput;
+
+ @Before
+ public void setup() throws Exception {
+ addUserTokens();
+ addAdminTokens();
+
+ String id = "testToken";
+ authTokenInput = new AuthTokenInput();
+ authTokenInput.id = id;
+ }
+
+ @Test
+ public void assertGenerateOwnTokenSucceeds() throws Exception {
+ RestResponse resp =
+ userRestSession.put(
+ String.format("/accounts/self/tokens/%s", authTokenInput.id), authTokenInput);
+ resp.assertCreated();
+
+ JsonObject createdToken = JsonParser.parseReader(resp.getReader()).getAsJsonObject();
+ assertThat(createdToken.get("id").getAsString()).isEqualTo(authTokenInput.id);
+ assertThat(createdToken.get("token").getAsString()).isNotNull();
+
+ assertThat(tokenAccessor.getToken(user.id(), authTokenInput.id)).isPresent();
+ }
+
+ @Test
+ public void assertCreateTokenForOtherUserFailsForNonAdmins() throws Exception {
+ userRestSession
+ .put(
+ String.format("/accounts/%d/tokens/%s", admin.id().get(), authTokenInput.id),
+ authTokenInput)
+ .assertForbidden();
+ }
+
+ @Test
+ public void assertCreateTokenForOtherUserSucceedsForAdmins() throws Exception {
+ adminRestSession
+ .put(
+ String.format("/accounts/%d/tokens/%s", user.id().get(), authTokenInput.id),
+ authTokenInput)
+ .assertCreated();
+ }
+
+ @Test
+ public void assertSetSpecificTokenFailsForNonAdmins() throws Exception {
+ authTokenInput.token = "secret";
+ userRestSession
+ .put(
+ String.format("/accounts/%d/tokens/%s", user.id().get(), authTokenInput.id),
+ authTokenInput)
+ .assertForbidden();
+ }
+
+ @Test
+ public void assertSetSpecificTokenSucceedsForAdmins() throws Exception {
+ authTokenInput.token = "secret";
+ RestResponse resp =
+ adminRestSession.put(
+ String.format("/accounts/%d/tokens/%s", user.id().get(), authTokenInput.id),
+ authTokenInput);
+
+ resp.assertCreated();
+
+ JsonObject createdToken = JsonParser.parseReader(resp.getReader()).getAsJsonObject();
+ assertThat(createdToken.get("id").getAsString()).isEqualTo(authTokenInput.id);
+ assertThat(createdToken.get("token").getAsString()).isEqualTo(authTokenInput.token);
+
+ assertThat(tokenAccessor.getToken(user.id(), authTokenInput.id)).isPresent();
+ }
+
+ @Test
+ public void assertListTokensSucceeds() throws Exception {
+ RestResponse resp = userRestSession.get(String.format("/accounts/%d/tokens", user.id().get()));
+ resp.assertOK();
+
+ JsonArray json = JsonParser.parseReader(resp.getReader()).getAsJsonArray();
+ assertThat(json.size()).isEqualTo(1);
+ assertThat(json.get(0).getAsJsonObject().get("id").getAsString()).isEqualTo("userToken1");
+ }
+
+ @Test
+ public void assertListTokensForOtherUserSucceedsForAdmins() throws Exception {
+ adminRestSession.get(String.format("/accounts/%d/tokens", user.id().get())).assertOK();
+ }
+
+ @Test
+ public void assertListTokensForOtherUserFailsForNonAdmins() throws Exception {
+ userRestSession.get(String.format("/accounts/%d/tokens", admin.id().get())).assertForbidden();
+ }
+
+ @Test
+ public void assertDeleteTokenSucceeds() throws Exception {
+ userRestSession
+ .delete(String.format("/accounts/%d/tokens/userToken1", user.id().get()))
+ .assertNoContent();
+ assertThat(tokenAccessor.getToken(user.id(), "userToken1")).isEmpty();
+ }
+
+ @Test
+ public void assertDeleteTokenForOtherUserSucceedsForAdmins() throws Exception {
+ adminRestSession
+ .delete(String.format("/accounts/%d/tokens/userToken1", user.id().get()))
+ .assertNoContent();
+ }
+
+ @Test
+ public void assertDeleteTokenForOtherUserFailsForNonAdmins() throws Exception {
+ userRestSession
+ .delete(String.format("/accounts/%d/tokens/adminToken1", admin.id().get()))
+ .assertForbidden();
+ }
+
+ @Test
+ public void assertCreateTokensWithLifetimeSucceeds() throws Exception {
+ for (String lifetime : List.of("5min", "1h", "1d", "1mon", "3y")) {
+ authTokenInput.lifetime = lifetime;
+ authTokenInput.id = String.format("testToken_%s", lifetime);
+ RestResponse resp =
+ userRestSession.put(
+ String.format("/accounts/self/tokens/%s", authTokenInput.id), authTokenInput);
+ resp.assertCreated();
+
+ JsonObject createdToken = JsonParser.parseReader(resp.getReader()).getAsJsonObject();
+ assertThat(createdToken.get("id").getAsString()).isEqualTo(authTokenInput.id);
+ assertThat(createdToken.get("token").getAsString()).isNotNull();
+ assertThat(
+ TimeUnit.NANOSECONDS.toMinutes(
+ Math.abs(
+ Timestamp.valueOf(createdToken.get("expiration").getAsString())
+ .toInstant()
+ .compareTo(
+ Instant.now()
+ .plusSeconds(
+ ConfigUtil.getTimeUnit(lifetime, 0, TimeUnit.SECONDS))))))
+ .isLessThan(1L);
+
+ assertThat(tokenAccessor.getToken(user.id(), authTokenInput.id)).isPresent();
+ }
+ }
+
+ @Test
+ public void assertInvalidLifetimeFormatReturnsBadRequest() throws Exception {
+ authTokenInput.lifetime = "1invalid";
+ RestResponse resp =
+ userRestSession.put(
+ String.format("/accounts/self/tokens/%s", authTokenInput.id), authTokenInput);
+ resp.assertBadRequest();
+ }
+
+ @Test
+ @GerritConfig(name = "auth.maxAuthTokensPerAccount", value = "2")
+ public void assertCreatingMoreTokensThanAllowedFails() throws Exception {
+ RestResponse resp =
+ userRestSession.put(
+ String.format("/accounts/self/tokens/%s", authTokenInput.id), authTokenInput);
+ resp.assertCreated();
+
+ AuthTokenInput tokenInput2 = new AuthTokenInput();
+ tokenInput2.id = "testToken2";
+ resp =
+ userRestSession.put(String.format("/accounts/self/tokens/%s", tokenInput2.id), tokenInput2);
+ resp.assertBadRequest();
+
+ resp = userRestSession.delete(String.format("/accounts/self/tokens/%s", authTokenInput.id));
+ resp.assertNoContent();
+
+ resp =
+ userRestSession.put(String.format("/accounts/self/tokens/%s", tokenInput2.id), tokenInput2);
+ resp.assertCreated();
+ }
+
+ private void addUserTokens() throws Exception {
+ @SuppressWarnings("unused")
+ var unused =
+ tokenAccessor.addPlainToken(user.id(), "userToken1", "http-pass", Optional.empty());
+ }
+
+ private void addAdminTokens() throws Exception {
+ @SuppressWarnings("unused")
+ var unused =
+ tokenAccessor.addPlainToken(admin.id(), "adminToken1", "http-pass", Optional.empty());
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index c34625e..298884c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -291,9 +291,12 @@
submitWithConflict(
change2.getChangeId(),
String.format(
- "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n"
- + "a.txt",
+ """
+ Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.
+
+ merge conflict(s):
+ * a.txt
+ """,
change2.getCommit().name(), change2.getChange().getId()));
RevCommit head = projectOperations.project(project).getHead("master");
assertThat(head).isEqualTo(headAfterFirstSubmit);
@@ -419,9 +422,12 @@
submitWithConflict(
change2.getChangeId(),
String.format(
- "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n"
- + "fileName 2",
+ """
+ Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.
+
+ merge conflict(s):
+ * fileName 2
+ """,
change2.getCommit().name(), change2.getChange().getId()));
assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index fb76171..4cb1733 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,6 +15,8 @@
package com.google.gerrit.acceptance.rest.change;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationInfoListener;
import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener;
import static com.google.gerrit.acceptance.TestExtensions.TestValidationOptionsListener;
@@ -54,6 +56,7 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -75,6 +78,7 @@
import com.google.gerrit.extensions.common.DiffInfo;
import com.google.gerrit.extensions.common.GitPerson;
import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.extensions.common.RevisionInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -100,6 +104,8 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
@@ -109,7 +115,9 @@
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
import org.eclipse.jgit.util.Base64;
import org.junit.Before;
import org.junit.Test;
@@ -226,8 +234,12 @@
gApi.changes().id(info.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
assertThat(currentRevision.conflicts).isNotNull();
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
+ assertThat(currentRevision.conflicts.base).isNull();
assertThat(currentRevision.conflicts.ours).isNull();
assertThat(currentRevision.conflicts.theirs).isNull();
+ assertThat(currentRevision.conflicts.mergeStrategy).isNull();
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.NO_MERGE_PERFORMED);
}
@Test
@@ -719,16 +731,41 @@
}
@Test
- public void createMergeChange() throws Exception {
+ public void createMergeChangeNoConflictsUsingResolveStrategy() throws Exception {
+ testCreateMergeChangeNoConflicts("resolve");
+ }
+
+ @Test
+ public void createMergeChangeNoConflictsUsingRecursiveStrategy() throws Exception {
+ testCreateMergeChangeNoConflicts("recursive");
+ }
+
+ @Test
+ public void createMergeChangeNoConflictsUsingSimpleTwoWayInCoreStrategy() throws Exception {
+ testCreateMergeChangeNoConflicts("simple-two-way-in-core");
+ }
+
+ @Test
+ public void createMergeChangeNoConflictsUsingOursStrategy() throws Exception {
+ testCreateMergeChangeNoConflicts("ours");
+ }
+
+ @Test
+ public void createMergeChangeNoConflictsUsingTheirsStrategy() throws Exception {
+ testCreateMergeChangeNoConflicts("theirs");
+ }
+
+ public void testCreateMergeChangeNoConflicts(String mergeStrategy) throws Exception {
String sourceBranch = "sourceBranch";
String targetBranch = "targetBranch";
ImmutableMap<String, Result> results =
changeInTwoBranches(sourceBranch, "a.txt", targetBranch, "b.txt");
- ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, "");
+ RevCommit baseCommit = results.get("master").getCommit();
+ ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, mergeStrategy);
ChangeInfo change = assertCreateSucceeds(in);
// Verify the message that has been posted on the change.
- List<ChangeMessageInfo> messages = gApi.changes().id(change._number).messages();
+ List<ChangeMessageInfo> messages = gApi.changes().id(project.get(), change._number).messages();
assertThat(messages).hasSize(1);
assertThat(Iterables.getOnlyElement(messages).message).isEqualTo("Uploaded patch set 1.");
@@ -742,7 +779,17 @@
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts.theirs)
.isEqualTo(results.get(sourceBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(mergeStrategy);
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
+
+ if ("ours".equals(mergeStrategy) || "theirs".equals(mergeStrategy)) {
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.ONE_SIDED_MERGE_STRATEGY);
+ } else {
+ assertThat(currentRevision.conflicts.base).isEqualTo(baseCommit.name());
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
+ }
}
@Test
@@ -762,19 +809,54 @@
}
@Test
- public void createMergeChange_Conflicts() throws Exception {
- changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
- ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
- assertCreateFails(in, RestApiException.class, "merge conflict");
+ public void createMergeChangeFailsDueToConflictsUsingResolveStrategy() throws Exception {
+ testCreateMergeChangeFailsDueToConflicts("resolve");
}
@Test
- public void createMergeChange_Conflicts_Ours() throws Exception {
+ public void createMergeChangeFailsDueToConflictsUsingRecursiveStrategy() throws Exception {
+ testCreateMergeChangeFailsDueToConflicts("recursive");
+ }
+
+ @Test
+ public void createMergeChangeFailsDueToConflictsUsingSimpleTwoWayInCoreStrategy()
+ throws Exception {
+ testCreateMergeChangeFailsDueToConflicts("simple-two-way-in-core");
+ }
+
+ private void testCreateMergeChangeFailsDueToConflicts(String mergeStrategy) throws Exception {
+ String fileName = "shared.txt";
+ changeInTwoBranches("branchA", fileName, "branchB", fileName);
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", mergeStrategy);
+ assertCreateFails(
+ in,
+ RestApiException.class,
+ "simple-two-way-in-core".equals(mergeStrategy)
+ ? "merge conflict(s)"
+ : String.format(
+ """
+ merge conflict(s):
+ * %s
+ """,
+ fileName));
+ }
+
+ @Test
+ public void createMergeChangeSucceedsWithConflictsUsingOursStrategy() throws Exception {
+ testCreateMergeChangeSucceedsWithConflicts("ours");
+ }
+
+ @Test
+ public void createMergeChangeSucceedsWithConflictsUsingTheirsStrategy() throws Exception {
+ testCreateMergeChangeSucceedsWithConflicts("theirs");
+ }
+
+ private void testCreateMergeChangeSucceedsWithConflicts(String mergeStrategy) throws Exception {
String sourceBranch = "sourceBranch";
String targetBranch = "targetBranch";
ImmutableMap<String, Result> results =
changeInTwoBranches(sourceBranch, "shared.txt", targetBranch, "shared.txt");
- ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, "ours");
+ ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, mergeStrategy);
ChangeInfo change = assertCreateSucceeds(in);
// Verify the conflicts information
@@ -783,25 +865,79 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
assertThat(currentRevision.conflicts.ours)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts.theirs)
.isEqualTo(results.get(sourceBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(mergeStrategy);
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.ONE_SIDED_MERGE_STRATEGY);
assertThat(currentRevision.conflicts.containsConflicts).isFalse();
}
@Test
- public void createMergeChangeWithConflictsAllowed() throws Exception {
- testCreateMergeChangeConflictsAllowed(/* useDiff3= */ false);
+ public void createMergeChangeWithConflictsAllowedUsingRecursiveStrategy() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "recursive", /* useDiff3= */ false);
}
@Test
@GerritConfig(name = "change.diff3ConflictView", value = "true")
- public void createMergeChangeWithConflictsAllowedUsingDiff3() throws Exception {
- testCreateMergeChangeConflictsAllowed(/* useDiff3= */ true);
+ public void createMergeChangeWithConflictsAllowedUsingRecursivetrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "recursive", /* useDiff3= */ true);
}
- private void testCreateMergeChangeConflictsAllowed(boolean useDiff3) throws Exception {
+ @Test
+ public void createMergeChangeWithConflictsAllowedUsingResolveStrategy() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "resolve", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void createMergeChangeWithConflictsAllowedUsingResolveStrategyAndDiff3() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "resolve", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void createMergeChangeWithConflictsAllowedUsingSimpleTwoWayInCoreStrategy()
+ throws Exception {
+ testCreateMergeChangeConflictsAllowed(
+ /* strategy= */ "simple-two-way-in-core", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void createMergeChangeWithConflictsAllowedUsingSimpleTwoWayInCoreStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeConflictsAllowed(
+ /* strategy= */ "simple-two-way-in-core", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void createMergeChangeWithConflictsAllowedUsingOursStrategy() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "ours", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void createMergeChangeWithConflictsAllowedUsingOursStrategyAndDiff3() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "ours", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void createMergeChangeWithConflictsAllowedUsingTheirsStrategy() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "theirs", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void createMergeChangeWithConflictsAllowedUsingTheirsStrategyAndDiff3() throws Exception {
+ testCreateMergeChangeConflictsAllowed(/* strategy= */ "theirs", /* useDiff3= */ true);
+ }
+
+ private void testCreateMergeChangeConflictsAllowed(String strategy, boolean useDiff3)
+ throws Exception {
String fileName = "shared.txt";
String sourceBranch = "sourceBranch";
String sourceSubject = "source change";
@@ -809,6 +945,7 @@
String targetBranch = "targetBranch";
String targetSubject = "target change";
String targetContent = "target content";
+
ImmutableMap<String, Result> results =
changeInTwoBranches(
sourceBranch,
@@ -819,8 +956,49 @@
targetSubject,
fileName,
targetContent);
+ RevCommit baseCommit = results.get("master").getCommit();
ChangeInput in =
- newMergeChangeInput(targetBranch, sourceBranch, "", /* allowConflicts= */ true);
+ newMergeChangeInput(targetBranch, sourceBranch, strategy, /* allowConflicts= */ true);
+
+ if ("ours".equals(strategy) || "theirs".equals(strategy)) {
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ // Verify the conflicts information
+ RevisionInfo currentRevision =
+ gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(currentRevision.commit.parents.get(0).commit)
+ .isEqualTo(results.get(targetBranch).getCommit().name());
+ assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.ours)
+ .isEqualTo(results.get(targetBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.theirs)
+ .isEqualTo(results.get(sourceBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.ONE_SIDED_MERGE_STRATEGY);
+ assertThat(currentRevision.conflicts.containsConflicts).isFalse();
+
+ // Verify that the file content in the created change is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ BinaryResult bin =
+ gApi.changes().id(project.get(), change._number).current().file(fileName).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent).isEqualTo("ours".equals(strategy) ? targetContent : sourceContent);
+
+ return;
+ }
+
+ if ("simple-two-way-in-core".equals(strategy)) {
+ assertCreateFails(
+ in,
+ BadRequestException.class,
+ "merge with conflicts is not supported with merge strategy: simple-two-way-in-core");
+ return;
+ }
+
ChangeInfo change = assertCreateSucceedsWithConflicts(in);
// Verify the conflicts information
@@ -829,36 +1007,43 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(baseCommit.name());
assertThat(currentRevision.conflicts.ours)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts.theirs)
.isEqualTo(results.get(sourceBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
// Verify that the file content in the created change is correct.
// We expect that it has conflict markers to indicate the conflict.
- BinaryResult bin = gApi.changes().id(change._number).current().file(fileName).content();
+ BinaryResult bin =
+ gApi.changes().id(project.get(), change._number).current().file(fileName).content();
ByteArrayOutputStream os = new ByteArrayOutputStream();
bin.writeTo(os);
String fileContent = new String(os.toByteArray(), UTF_8);
assertThat(fileContent)
.isEqualTo(
- "<<<<<<< TARGET BRANCH ("
- + projectOperations.project(project).getHead(targetBranch).getName()
- + " "
- + targetSubject
- + ")\n"
- + targetContent
- + "\n"
- + (useDiff3 ? "||||||| BASE\n" : "")
- + "=======\n"
- + sourceContent
- + "\n"
- + ">>>>>>> SOURCE BRANCH ("
- + projectOperations.project(project).getHead(sourceBranch).getName()
- + " "
- + sourceSubject
- + ")\n");
+ String.format(
+ """
+ <<<<<<< TARGET BRANCH (%s %s)
+ %s
+ %s=======
+ %s
+ >>>>>>> SOURCE BRANCH (%s %s)
+ """,
+ projectOperations.project(project).getHead(targetBranch).getName(),
+ targetSubject,
+ targetContent,
+ (useDiff3
+ ? String.format(
+ "||||||| BASE (%s %s)\n",
+ baseCommit.getName(), baseCommit.getShortMessage())
+ : ""),
+ sourceContent,
+ projectOperations.project(project).getHead(sourceBranch).getName(),
+ sourceSubject));
// Verify the message that has been posted on the change.
List<ChangeMessageInfo> messages = gApi.changes().id(change._number).messages();
@@ -873,26 +1058,654 @@
}
@Test
- public void createMergeChange_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+ public void createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingRecursiveStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "recursive", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingRecursiveStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "recursive", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingResolveStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "resolve", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingResolveStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "resolve", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingUsingSimpleTwoWayInCoreStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "simple-two-way-in-core", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingUsingSimpleTwoWayInCoreStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "simple-two-way-in-core", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingUsingOursStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "ours", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingUsingOursStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "ours", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingUsingTheirsStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "theirs", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoInitialCommitsWithConflictsAllowedUsingUsingTheirsStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ /* strategy= */ "theirs", /* useDiff3= */ true);
+ }
+
+ private void testCreateMergeChangeBetweenTwoInitialCommitsConflictsAllowed(
+ String strategy, boolean useDiff3) throws Exception {
String fileName = "shared.txt";
String sourceBranch = "sourceBranch";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
String targetBranch = "targetBranch";
- changeInTwoBranches(
- sourceBranch,
- "source change",
- fileName,
- "source content",
- targetBranch,
- "target change",
- fileName,
- "target content");
- String mergeStrategy = "simple-two-way-in-core";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+
+ Project.NameKey projectWithoutInitialCommit =
+ projectOperations.newProject().createEmptyCommit(false).create();
+
+ // Create sourceBranch with an initial commit.
+ TestRepository<InMemoryRepository> testRepo =
+ cloneProject(projectWithoutInitialCommit, getCloneAsAccount(configRule.description()));
+ RevCommit initialCommitSource =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message(sourceSubject)
+ .insertChangeId()
+ .add(fileName, sourceContent)
+ .create());
+ testRepo.reset(initialCommitSource);
+ PushResult r = pushHead(testRepo, "refs/heads/" + sourceBranch);
+ assertThat(r.getRemoteUpdate("refs/heads/" + sourceBranch).getStatus()).isEqualTo(Status.OK);
+
+ // Create targetBranch with another initial commit.
+ RevCommit initialCommitTarget =
+ testRepo.parseBody(
+ testRepo
+ .commit()
+ .message(targetSubject)
+ .insertChangeId()
+ .add(fileName, targetContent)
+ .create());
+ testRepo.reset(initialCommitTarget);
+ r = pushHead(testRepo, "refs/heads/" + targetBranch);
+ assertThat(r.getRemoteUpdate("refs/heads/" + targetBranch).getStatus()).isEqualTo(Status.OK);
+
+ // Merge sourceBranch into targetBranch with conflicts allowed.
ChangeInput in =
- newMergeChangeInput(targetBranch, sourceBranch, mergeStrategy, /* allowConflicts= */ true);
- assertCreateFails(
- in,
- BadRequestException.class,
- "merge with conflicts is not supported with merge strategy: " + mergeStrategy);
+ newMergeChangeInput(
+ projectWithoutInitialCommit,
+ targetBranch,
+ sourceBranch,
+ /* strategy= */ strategy,
+ /* allowConflicts= */ true);
+
+ if ("ours".equals(strategy) || "theirs".equals(strategy)) {
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ // Verify the conflicts information.
+ RevisionInfo currentRevision =
+ gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(currentRevision.commit.parents.get(0).commit)
+ .isEqualTo(initialCommitTarget.name());
+ assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.ours).isEqualTo(initialCommitTarget.name());
+ assertThat(currentRevision.conflicts.theirs).isEqualTo(initialCommitSource.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.ONE_SIDED_MERGE_STRATEGY);
+ assertThat(currentRevision.conflicts.containsConflicts).isFalse();
+
+ // Verify that the file content in the created change is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ BinaryResult bin =
+ gApi.changes()
+ .id(projectWithoutInitialCommit.get(), change._number)
+ .current()
+ .file(fileName)
+ .content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent).isEqualTo("ours".equals(strategy) ? targetContent : sourceContent);
+
+ return;
+ }
+
+ if ("simple-two-way-in-core".equals(strategy)) {
+ assertCreateFails(
+ in,
+ BadRequestException.class,
+ "merge with conflicts is not supported with merge strategy: simple-two-way-in-core");
+ return;
+ }
+
+ ChangeInfo change = assertCreateSucceedsWithConflicts(in);
+
+ // Verify the conflicts information.
+ RevisionInfo currentRevision =
+ gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(initialCommitTarget.name());
+ assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.ours).isEqualTo(initialCommitTarget.name());
+ assertThat(currentRevision.conflicts.theirs).isEqualTo(initialCommitSource.name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.NO_COMMON_ANCESTOR);
+ assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+
+ // Verify that the file content in the created change is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ BinaryResult bin =
+ gApi.changes()
+ .id(projectWithoutInitialCommit.get(), change._number)
+ .current()
+ .file(fileName)
+ .content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent)
+ .isEqualTo(
+ String.format(
+ """
+ <<<<<<< TARGET BRANCH (%s %s)
+ %s
+ %s=======
+ %s
+ >>>>>>> SOURCE BRANCH (%s %s)
+ """,
+ projectOperations
+ .project(projectWithoutInitialCommit)
+ .getHead(targetBranch)
+ .getName(),
+ targetSubject,
+ targetContent,
+ (useDiff3 ? "||||||| BASE (no common ancestor)\n" : ""),
+ sourceContent,
+ projectOperations
+ .project(projectWithoutInitialCommit)
+ .getHead(sourceBranch)
+ .getName(),
+ sourceSubject));
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages =
+ gApi.changes().id(projectWithoutInitialCommit.get(), change._number).messages();
+ assertThat(messages).hasSize(1);
+ assertThat(Iterables.getOnlyElement(messages).message)
+ .isEqualTo(
+ String.format(
+ """
+ Uploaded patch set 1.
+
+ The following files contain Git conflicts:
+ * %s
+ """,
+ fileName));
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingRecursiveStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "recursive", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingRecursiveStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "recursive", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingResolveStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "resolve", /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingResolveStrategyAndDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "resolve", /* useDiff3= */ true);
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingOursStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "ours", /* useDiff3= */ false);
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingTheirsStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "theirs", /* useDiff3= */ false);
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesWithConflictsAllowedUsingSimpleTwoWayInCoreStrategy()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ /* strategy= */ "simple-two-way-in-core", /* useDiff3= */ false);
+ }
+
+ private void testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleMergeBasesConflictsAllowed(
+ String strategy, boolean useDiff3) throws Exception {
+ String sourceBranch = "sourceBranch";
+ String targetBranch = "targetBranch";
+
+ // Create source and target branch with non-conflicting commits.
+ // Later these commits will become the base commits for the criss-cross-merge.
+ ImmutableMap<String, Result> results =
+ changeInTwoBranches(
+ sourceBranch,
+ "base 1",
+ "a.txt",
+ "a content",
+ targetBranch,
+ "base 2",
+ "b.txt",
+ "b content");
+ RevCommit baseCommitInSource = results.get(sourceBranch).getCommit();
+ RevCommit baseCommitInTarget = results.get(targetBranch).getCommit();
+
+ // Create merge commits in both branches (1. merge the target branch into the source branch, 2.
+ // merge the source branch into the target branch).
+ PushOneCommit mergeCommitInSource = pushFactory.create(user.newIdent(), testRepo);
+ mergeCommitInSource.setParents(ImmutableList.of(baseCommitInSource, baseCommitInTarget));
+ mergeCommitInSource.to("refs/heads/" + sourceBranch).assertOkStatus();
+ PushOneCommit mergeCommitInTarget = pushFactory.create(user.newIdent(), testRepo);
+ mergeCommitInTarget.setParents(ImmutableList.of(baseCommitInTarget, baseCommitInSource));
+ mergeCommitInTarget.to("refs/heads/" + targetBranch).assertOkStatus();
+
+ // Create conflicting commits in both branches.
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ PushOneCommit pushConflictingCommitInSource =
+ pushFactory.create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent);
+ pushConflictingCommitInSource.setParent(
+ projectOperations.project(project).getHead(sourceBranch));
+ PushOneCommit.Result pushConflictingCommitInSourceResult =
+ pushConflictingCommitInSource.to("refs/heads/" + sourceBranch);
+ pushConflictingCommitInSourceResult.assertOkStatus();
+ PushOneCommit pushConflictingCommitInTarget =
+ pushFactory.create(user.newIdent(), testRepo, targetSubject, fileName, targetContent);
+ pushConflictingCommitInTarget.setParent(
+ projectOperations.project(project).getHead(targetBranch));
+ PushOneCommit.Result pushConflictingCommitInTargetResult =
+ pushConflictingCommitInTarget.to("refs/heads/" + targetBranch);
+ pushConflictingCommitInTargetResult.assertOkStatus();
+
+ // Merge the source branch into the target with conflicts allowed. This is a criss-cross-merge:
+ //
+ // (criss-cross-merge)
+ // / \
+ // (conflictingCommitInSource) (conflictingCommitInTarget)
+ // | |
+ // (mergeCommitInSource) (mergeCommitInTarget)
+ // | X |
+ // (baseCommitInSource) (baseCommitInTarget)
+ // \ /
+ // (initialCommit)
+ ChangeInput mergeInput =
+ newMergeChangeInput(targetBranch, sourceBranch, strategy, /* allowConflicts= */ true);
+
+ // The resolve/simple-two-way-in-core strategy doesn't support criss-cross-merges.
+ if ("resolve".equals(strategy) || "simple-two-way-in-core".equals(strategy)) {
+ assertCreateFails(
+ mergeInput,
+ ResourceConflictException.class,
+ "Cannot create merge commit: No merge base could be determined."
+ + " Reason=MULTIPLE_MERGE_BASES_NOT_SUPPORTED.");
+ return;
+ }
+
+ // The ours/theirs strategy never results in conflicts.
+ if ("ours".equals(strategy) || "theirs".equals(strategy)) {
+ ChangeInfo change = assertCreateSucceeds(mergeInput);
+
+ // Verify the conflicts information
+ RevisionInfo currentRevision =
+ gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(currentRevision.commit.parents.get(0).commit)
+ .isEqualTo(pushConflictingCommitInTargetResult.getCommit().name());
+ assertThat(currentRevision.commit.parents.get(1).commit)
+ .isEqualTo(pushConflictingCommitInSourceResult.getCommit().name());
+ assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.ours)
+ .isEqualTo(pushConflictingCommitInTargetResult.getCommit().name());
+ assertThat(currentRevision.conflicts.theirs)
+ .isEqualTo(pushConflictingCommitInSourceResult.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason)
+ .isEqualTo(NoMergeBaseReason.ONE_SIDED_MERGE_STRATEGY);
+ assertThat(currentRevision.conflicts.containsConflicts).isFalse();
+
+ // Verify that the file content in the created change is correct.
+ // We expect that it doesn't have conflict markers and the content from "ours" version was
+ // used.
+ BinaryResult bin =
+ gApi.changes().id(project.get(), change._number).current().file(fileName).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent).isEqualTo("ours".equals(strategy) ? targetContent : sourceContent);
+
+ return;
+ }
+
+ assume().that(strategy).isEqualTo("recursive");
+ ChangeInfo change = assertCreateSucceedsWithConflicts(mergeInput);
+
+ // Verify the conflicts information
+ RevisionInfo currentRevision =
+ gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(currentRevision.commit.parents.get(0).commit)
+ .isEqualTo(pushConflictingCommitInTargetResult.getCommit().name());
+ assertThat(currentRevision.commit.parents.get(1).commit)
+ .isEqualTo(pushConflictingCommitInSourceResult.getCommit().name());
+ assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.ours)
+ .isEqualTo(pushConflictingCommitInTargetResult.getCommit().name());
+ assertThat(currentRevision.conflicts.theirs)
+ .isEqualTo(pushConflictingCommitInSourceResult.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo(strategy);
+ assertThat(currentRevision.conflicts.noBaseReason).isEqualTo(NoMergeBaseReason.COMPUTED_BASE);
+ assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+
+ // Verify that the file content in the created change is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ BinaryResult bin =
+ gApi.changes().id(project.get(), change._number).current().file(fileName).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent)
+ .isEqualTo(
+ String.format(
+ """
+ <<<<<<< TARGET BRANCH (%s %s)
+ %s
+ %s=======
+ %s
+ >>>>>>> SOURCE BRANCH (%s %s)
+ """,
+ pushConflictingCommitInTargetResult.getCommit().name(),
+ targetSubject,
+ targetContent,
+ (useDiff3 ? "||||||| BASE (computed base)\n" : ""),
+ sourceContent,
+ pushConflictingCommitInSourceResult.getCommit().name(),
+ sourceSubject));
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(project.get(), change._number).messages();
+ assertThat(messages).hasSize(1);
+ assertThat(Iterables.getOnlyElement(messages).message)
+ .isEqualTo(
+ String.format(
+ """
+ Uploaded patch set 1.
+
+ The following files contain Git conflicts:
+ * %s
+ """,
+ fileName));
+ }
+
+ @Test
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleConflictingMergeBasesWithConflictsAllowed()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleConflictingMergeBasesConflictsAllowed(
+ /* useDiff3= */ false);
+ }
+
+ @Test
+ @GerritConfig(name = "change.diff3ConflictView", value = "true")
+ public void
+ createMergeChangeBetweenTwoCommitsThatHaveMultipleConflictingMergeBasesWithConflictsAllowedUsingDiff3()
+ throws Exception {
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleConflictingMergeBasesConflictsAllowed(
+ /* useDiff3= */ true);
+ }
+
+ private void
+ testCreateMergeChangeBetweenTwoCommitsThatHaveMultipleConflictingMergeBasesConflictsAllowed(
+ boolean useDiff3) throws Exception {
+ String sourceBranch = "sourceBranch";
+ String targetBranch = "targetBranch";
+
+ // Create source and target branch with conflicting commits.
+ // Later these commits will become the base commits for the criss-cross-merge.
+ String baseFile = "base.txt";
+ String baseContentSource = "base source";
+ String baseContentTarget = "base target";
+ ImmutableMap<String, Result> results =
+ changeInTwoBranches(
+ sourceBranch,
+ "base 1",
+ baseFile,
+ baseContentSource,
+ targetBranch,
+ "base 2",
+ baseFile,
+ baseContentTarget);
+ RevCommit baseCommitInSource = results.get(sourceBranch).getCommit();
+ RevCommit baseCommitInTarget = results.get(targetBranch).getCommit();
+
+ // Create merge commits in both branches (1. merge the target branch into the source branch, 2.
+ // merge the source branch into the target branch).
+ PushOneCommit mergeCommitInSource =
+ pushFactory.create(
+ user.newIdent(), testRepo, "Merge in Source", baseFile, baseContentSource);
+ mergeCommitInSource.setParents(ImmutableList.of(baseCommitInSource, baseCommitInTarget));
+ mergeCommitInSource.to("refs/heads/" + sourceBranch).assertOkStatus();
+ PushOneCommit mergeCommitInTarget =
+ pushFactory.create(
+ user.newIdent(), testRepo, "Merge in Target", baseFile, baseContentTarget);
+ mergeCommitInTarget.setParents(ImmutableList.of(baseCommitInTarget, baseCommitInSource));
+ mergeCommitInTarget.to("refs/heads/" + targetBranch).assertOkStatus();
+
+ // Create conflicting commits in both branches.
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ PushOneCommit pushConflictingCommitInSource =
+ pushFactory.create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent);
+ pushConflictingCommitInSource.setParent(
+ projectOperations.project(project).getHead(sourceBranch));
+ PushOneCommit.Result pushConflictingCommitInSourceResult =
+ pushConflictingCommitInSource.to("refs/heads/" + sourceBranch);
+ pushConflictingCommitInSourceResult.assertOkStatus();
+ PushOneCommit pushConflictingCommitInTarget =
+ pushFactory.create(user.newIdent(), testRepo, targetSubject, fileName, targetContent);
+ pushConflictingCommitInTarget.setParent(
+ projectOperations.project(project).getHead(targetBranch));
+ PushOneCommit.Result pushConflictingCommitInTargetResult =
+ pushConflictingCommitInTarget.to("refs/heads/" + targetBranch);
+ pushConflictingCommitInTargetResult.assertOkStatus();
+
+ // Merge the source branch into the target with conflicts allowed. This is a criss-cross-merge:
+ //
+ // (criss-cross-merge)
+ // / \
+ // (conflictingCommitInSource) (conflictingCommitInTarget)
+ // | |
+ // (mergeCommitInSource) (mergeCommitInTarget)
+ // | X |
+ // (baseCommitInSource) (baseCommitInTarget)
+ // \ /
+ // (initialCommit)
+ ChangeInput mergeInput =
+ newMergeChangeInput(targetBranch, sourceBranch, "recursive", /* allowConflicts= */ true);
+
+ ChangeInfo change = assertCreateSucceedsWithConflicts(mergeInput);
+
+ // Verify the conflicts information
+ RevisionInfo currentRevision =
+ gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(currentRevision.commit.parents.get(0).commit)
+ .isEqualTo(pushConflictingCommitInTargetResult.getCommit().name());
+ assertThat(currentRevision.commit.parents.get(1).commit)
+ .isEqualTo(pushConflictingCommitInSourceResult.getCommit().name());
+ assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isNull();
+ assertThat(currentRevision.conflicts.ours)
+ .isEqualTo(pushConflictingCommitInTargetResult.getCommit().name());
+ assertThat(currentRevision.conflicts.theirs)
+ .isEqualTo(pushConflictingCommitInSourceResult.getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isEqualTo(NoMergeBaseReason.COMPUTED_BASE);
+ assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+
+ // Verify that the content of the file that was conflicting in the bases is correct.
+ // We expect that it has conflict markers to indicate the conflict and that the base version
+ // that is computed by the recursive merge is a auto merge of the two bases, containing conflict
+ // markers as well.
+ String computedBaseContent =
+ String.format(
+ """
+ <<<<<<< OURS
+ %s
+ =======
+ %s
+ >>>>>>> THEIRS
+ """,
+ baseContentTarget, baseContentSource);
+ BinaryResult bin =
+ gApi.changes().id(project.get(), change._number).current().file(baseFile).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent)
+ .isEqualTo(
+ String.format(
+ """
+ <<<<<<< TARGET BRANCH (%s %s)
+ %s
+ %s=======
+ %s
+ >>>>>>> SOURCE BRANCH (%s %s)
+ """,
+ pushConflictingCommitInTargetResult.getCommit().name(),
+ targetSubject,
+ baseContentTarget,
+ (useDiff3 ? "||||||| BASE (computed base)\n" + computedBaseContent : ""),
+ baseContentSource,
+ pushConflictingCommitInSourceResult.getCommit().name(),
+ sourceSubject));
+
+ // Verify that the file content in the created change is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ bin = gApi.changes().id(project.get(), change._number).current().file(fileName).content();
+ os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent)
+ .isEqualTo(
+ String.format(
+ """
+ <<<<<<< TARGET BRANCH (%s %s)
+ %s
+ %s=======
+ %s
+ >>>>>>> SOURCE BRANCH (%s %s)
+ """,
+ pushConflictingCommitInTargetResult.getCommit().name(),
+ targetSubject,
+ targetContent,
+ (useDiff3 ? "||||||| BASE (computed base)\n" : ""),
+ sourceContent,
+ pushConflictingCommitInSourceResult.getCommit().name(),
+ sourceSubject));
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(project.get(), change._number).messages();
+ assertThat(messages).hasSize(1);
+ assertThat(Iterables.getOnlyElement(messages).message)
+ .isEqualTo(
+ String.format(
+ """
+ Uploaded patch set 1.
+
+ The following files contain Git conflicts:
+ * %s
+ * %s
+ """,
+ baseFile, fileName));
}
@Test
@@ -966,6 +1779,7 @@
"target change",
"shared.txt",
"target content");
+ RevCommit baseCommit = results.get("master").getCommit();
ChangeInput in =
newMergeChangeInput(targetBranch, sourceBranch, "", /* allowConflicts= */ true);
in.subject = "Merge " + sourceBranch + " to " + targetBranch;
@@ -978,10 +1792,13 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(baseCommit.name());
assertThat(currentRevision.conflicts.ours)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts.theirs)
.isEqualTo(results.get(sourceBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
// Update the commit message
@@ -1002,10 +1819,13 @@
assertThat(currentRevision.commit.parents.get(0).commit)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts).isNotNull();
+ assertThat(currentRevision.conflicts.base).isEqualTo(baseCommit.name());
assertThat(currentRevision.conflicts.ours)
.isEqualTo(results.get(targetBranch).getCommit().name());
assertThat(currentRevision.conflicts.theirs)
.isEqualTo(results.get(sourceBranch).getCommit().name());
+ assertThat(currentRevision.conflicts.mergeStrategy).isEqualTo("recursive");
+ assertThat(currentRevision.conflicts.noBaseReason).isNull();
assertThat(currentRevision.conflicts.containsConflicts).isTrue();
}
@@ -1703,14 +2523,24 @@
}
private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) {
- return newMergeChangeInput(targetBranch, sourceRef, strategy, /* allowConflicts= */ false);
+ return newMergeChangeInput(
+ project, targetBranch, sourceRef, strategy, /* allowConflicts= */ false);
}
private ChangeInput newMergeChangeInput(
String targetBranch, String sourceRef, String strategy, boolean allowConflicts) {
+ return newMergeChangeInput(project, targetBranch, sourceRef, strategy, allowConflicts);
+ }
+
+ private ChangeInput newMergeChangeInput(
+ Project.NameKey projectName,
+ String targetBranch,
+ String sourceRef,
+ String strategy,
+ boolean allowConflicts) {
// create a merge change from branchA to master in gerrit
ChangeInput in = new ChangeInput();
- in.project = project.get();
+ in.project = projectName.get();
in.branch = targetBranch;
in.subject = "merge " + sourceRef + " to " + targetBranch;
in.status = ChangeStatus.NEW;
diff --git a/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
index ee00e40..20c1c36 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
@@ -138,11 +138,10 @@
extIdNotes = externalIdNotesFactory.load(allUsersRepo);
ExternalId extId =
- externalIdFactory.create(
+ externalIdFactory.createWithEmail(
externalIdKeyFactory.create(SCHEME_USERNAME, "JonDoe", true),
accountId,
- "test@email.com",
- "w1m9Bg85GQ4hijLNxW+6xAfj4r9wyk9rzVQelIHxuQ");
+ "test@email.com");
extIdNotes.upsert(extId);
extIdNotes.commit(md);
diff --git a/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
index c14e9261..9dfcdbe 100644
--- a/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
@@ -23,19 +23,24 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.proto.Entities;
import com.google.gerrit.proto.testing.SerializedClassSubject;
import com.google.inject.TypeLiteral;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Objects;
+import java.util.stream.Collectors;
import org.junit.Test;
public class AccountInputProtoConverterTest {
private final AccountInputProtoConverter accountInputProtoConverter =
AccountInputProtoConverter.INSTANCE;
+ private final TokenInputProtoConverter tokenInputProtoConverter =
+ TokenInputProtoConverter.INSTANCE;
private AccountInput createAccountInputInstance() {
+
AccountInput accountInput = new AccountInput();
accountInput.username = "test-username";
accountInput.name = "test-name";
@@ -44,9 +49,22 @@
accountInput.sshKey = "test-ssh-key";
accountInput.httpPassword = "test-http-password";
accountInput.groups = List.of("group1", "group2");
+ accountInput.tokens = getTokens();
return accountInput;
}
+ private List<AuthTokenInput> getTokens() {
+ AuthTokenInput token1 = new AuthTokenInput();
+ token1.id = "id1";
+ token1.token = "secret";
+
+ AuthTokenInput token2 = new AuthTokenInput();
+ token2.id = "another_token";
+ token2.token = "123456";
+
+ return List.of(token1, token2);
+ }
+
private void assertAccountInputEquals(AccountInput expected, AccountInput actual) {
assertThat(
Objects.equals(expected.username, actual.username)
@@ -55,6 +73,7 @@
&& Objects.equals(expected.email, actual.email)
&& Objects.equals(expected.sshKey, actual.sshKey)
&& Objects.equals(expected.httpPassword, actual.httpPassword)
+ && Objects.equals(expected.tokens, actual.tokens)
&& Objects.equals(expected.groups, actual.groups))
.isTrue();
}
@@ -73,6 +92,10 @@
.setSshKey("test-ssh-key")
.setHttpPassword("test-http-password")
.addAllGroups(ImmutableList.of("group1", "group2"))
+ .addAllTokens(
+ getTokens().stream()
+ .map(tokenInputProtoConverter::toProto)
+ .collect(Collectors.toList()))
.build();
assertThat(proto).isEqualTo(expectedProto);
}
@@ -100,6 +123,7 @@
.put("sshKey", String.class)
.put("httpPassword", String.class)
.put("groups", new TypeLiteral<List<String>>() {}.getType())
+ .put("tokens", new TypeLiteral<List<AuthTokenInput>>() {}.getType())
.build());
}
}
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
index ffa99d9..e64a9e5 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
@@ -27,6 +27,7 @@
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.NotifyInfo;
import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ChangeInput;
@@ -69,6 +70,10 @@
accountInput.sshKey = "test-ssh-key";
accountInput.httpPassword = "test-http-password";
accountInput.groups = ImmutableList.of("test-group");
+ AuthTokenInput tokenInput = new AuthTokenInput();
+ tokenInput.id = "test-id";
+ tokenInput.token = "secret";
+ accountInput.tokens = ImmutableList.of(tokenInput);
return accountInput;
}
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 2efc11b..4f42f31 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -52,9 +52,13 @@
Optional.of(
PatchSet.Conflicts.create(
Optional.of(
+ ObjectId.fromString("76ce7b5cb6f176fb64d9e4406f83fe5d658e1136")),
+ Optional.of(
ObjectId.fromString("fc5fcbe1cf0b3e4c203ff4cb77c3912208174695")),
Optional.of(
ObjectId.fromString("f72b99f6898debf8d1ceeffb92faa66bb5cb7a9f")),
+ Optional.of("resolve"),
+ Optional.empty(),
true)))
.build();
@@ -77,6 +81,9 @@
.setBranch("refs/heads/master")
.setConflicts(
Entities.Conflicts.newBuilder()
+ .setBase(
+ Entities.ObjectId.newBuilder()
+ .setName("76ce7b5cb6f176fb64d9e4406f83fe5d658e1136"))
.setOurs(
Entities.ObjectId.newBuilder()
.setName("fc5fcbe1cf0b3e4c203ff4cb77c3912208174695"))
@@ -84,6 +91,7 @@
Entities.ObjectId.newBuilder()
.setName("f72b99f6898debf8d1ceeffb92faa66bb5cb7a9f"))
.setContainsConflicts(true)
+ .setMergeStrategy("resolve")
.build())
.build();
assertThat(proto).isEqualTo(expectedProto);
@@ -134,9 +142,13 @@
Optional.of(
PatchSet.Conflicts.create(
Optional.of(
+ ObjectId.fromString("76ce7b5cb6f176fb64d9e4406f83fe5d658e1136")),
+ Optional.of(
ObjectId.fromString("fc5fcbe1cf0b3e4c203ff4cb77c3912208174695")),
Optional.of(
ObjectId.fromString("f72b99f6898debf8d1ceeffb92faa66bb5cb7a9f")),
+ Optional.of("resolve"),
+ Optional.empty(),
true)))
.build();
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 0389c4f..0d30603 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -34,10 +34,12 @@
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.AuthToken;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.gerrit.server.account.AuthTokenVerifier;
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.PasswordVerifier;
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;
@@ -47,6 +49,7 @@
import java.time.Instant;
import java.util.Base64;
import java.util.Collection;
+import java.util.List;
import java.util.Optional;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
@@ -86,13 +89,15 @@
@Mock private WebSessionManager webSessionManager;
+ @Mock private AuthTokenAccessor tokenAccessor;
+
private WebSession webSession;
private FakeHttpServletRequest req;
private HttpServletResponse res;
private AuthResult authSuccessful;
private ExternalIdFactory extIdFactory;
private ExternalIdKeyFactory extIdKeyFactory;
- private PasswordVerifier pwdVerifier;
+ private AuthTokenVerifier tokenVerifier;
private AuthRequest.Factory authRequestFactory;
@Before
@@ -103,7 +108,7 @@
extIdKeyFactory = new ExternalIdKeyFactory(new ExternalIdKeyFactory.ConfigImpl(authConfig));
extIdFactory = new ExternalIdFactoryNoteDbImpl(extIdKeyFactory, authConfig);
authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
- pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
+ tokenVerifier = new AuthTokenVerifier(tokenAccessor);
authSuccessful =
new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
@@ -115,6 +120,9 @@
doReturn(webSessionValue)
.when(webSessionManager)
.createVal(any(), any(), eq(false), any(), any(), any());
+ doReturn(List.of(AuthToken.createWithPlainToken("token", AUTH_PASSWORD)))
+ .when(tokenAccessor)
+ .getValidTokens(AUTH_ACCOUNT_ID);
}
@Test
@@ -130,7 +138,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
@@ -152,7 +160,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
@@ -177,7 +185,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
@@ -204,7 +212,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
@@ -254,7 +262,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
@@ -279,7 +287,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
@@ -294,7 +302,7 @@
}
private void initAccount(Collection<ExternalId> extIds) throws Exception {
- Account account = Account.builder(Account.id(1000000), Instant.now()).build();
+ Account account = Account.builder(AUTH_ACCOUNT_ID, Instant.now()).build();
AccountState accountState = AccountState.forAccount(account, extIds);
doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
@@ -315,7 +323,7 @@
accountManager,
authConfig,
authRequestFactory,
- pwdVerifier);
+ tokenVerifier);
basicAuthFilter.doFilter(req, res, chain);
}
@@ -337,6 +345,7 @@
doReturn(webSession).when(webSessionItem).get();
}
+ @Deprecated
private ExternalId createUsernamePasswordExternalId() {
return extIdFactory.createWithPassword(
extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
diff --git a/javatests/com/google/gerrit/server/account/AuthTokenCacheTest.java b/javatests/com/google/gerrit/server/account/AuthTokenCacheTest.java
new file mode 100644
index 0000000..df7791a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/AuthTokenCacheTest.java
@@ -0,0 +1,50 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.doReturn;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AuthTokenCacheTest {
+ private static final Account.Id ACCOUNT_ID = Account.id(1);
+ private static final String PWD = "secret";
+
+ private AuthTokenCache.Loader cacheLoader;
+
+ @Mock private DirectAuthTokenAccessor tokenAccessor;
+
+ @Before
+ public void setUp() throws Exception {
+ doReturn(ImmutableList.of(AuthToken.createWithPlainToken("token", PWD)))
+ .when(tokenAccessor)
+ .getTokens(ACCOUNT_ID);
+ cacheLoader = new AuthTokenCache.Loader(tokenAccessor);
+ }
+
+ @Test
+ public void loadTokenFromAccount() throws Exception {
+ ImmutableList<AuthToken> tokens = cacheLoader.load(ACCOUNT_ID);
+ assertThat(HashedPassword.decode(tokens.get(0).hashedToken()).checkPassword(PWD)).isTrue();
+ }
+}
diff --git a/javatests/com/google/gerrit/server/account/AuthTokenVerifierTest.java b/javatests/com/google/gerrit/server/account/AuthTokenVerifierTest.java
new file mode 100644
index 0000000..eb8d6e7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/AuthTokenVerifierTest.java
@@ -0,0 +1,77 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.doReturn;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AuthTokenVerifierTest {
+ private static final Account.Id ACCOUNT_ID = Account.id(1);
+ private AuthTokenVerifier tokenVerifier;
+ private LoadingCache<Account.Id, List<AuthToken>> cache;
+ @Mock AuthTokenCache.Loader loader;
+
+ @Before
+ public void setUp() throws Exception {
+ ImmutableList<AuthToken> tokens =
+ ImmutableList.of(
+ AuthToken.createWithPlainToken("id1", "hashedToken"),
+ AuthToken.createWithPlainToken("id2", "another_Token"),
+ AuthToken.createWithPlainToken(
+ "id3", "tokenWithLifetime", Optional.of(Instant.now().plus(1, ChronoUnit.DAYS))),
+ AuthToken.createWithPlainToken(
+ "id4",
+ "tokenWithExpiredLifetime",
+ Optional.of(Instant.now().minus(1, ChronoUnit.DAYS))));
+ doReturn(tokens).when(loader).load(ACCOUNT_ID);
+ cache = CacheBuilder.newBuilder().build(loader);
+ AuthTokenCache authTokenCache = new AuthTokenCache(cache);
+ AuthTokenAccessor tokenAccessor = new CachingAuthTokenAccessor(authTokenCache, null);
+ tokenVerifier = new AuthTokenVerifier(tokenAccessor);
+ }
+
+ @Test
+ public void checkTokenWithoutLimitedLifetime() {
+ assertThat(tokenVerifier.checkToken(ACCOUNT_ID, "hashedToken")).isTrue();
+ assertThat(tokenVerifier.checkToken(ACCOUNT_ID, "another_Token")).isTrue();
+ assertThat(tokenVerifier.checkToken(ACCOUNT_ID, "invalid")).isFalse();
+ assertThat(tokenVerifier.checkToken(ACCOUNT_ID, "another_token")).isFalse();
+ }
+
+ @Test
+ public void assertThatTokensSucceedAuthenticationWithinLifetime() {
+ assertThat(tokenVerifier.checkToken(ACCOUNT_ID, "tokenWithLifetime")).isTrue();
+ }
+
+ @Test
+ public void assertThatExpiredTokensFailAuthentication() {
+ assertThat(tokenVerifier.checkToken(ACCOUNT_ID, "tokenWithExpiredLifetime")).isFalse();
+ }
+}
diff --git a/javatests/com/google/gerrit/server/account/DirectAuthTokenAccessorTest.java b/javatests/com/google/gerrit/server/account/DirectAuthTokenAccessorTest.java
new file mode 100644
index 0000000..cad6003d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/DirectAuthTokenAccessorTest.java
@@ -0,0 +1,100 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.doReturn;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import java.time.Instant;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DirectAuthTokenAccessorTest {
+ private static final Account.Id ACCOUNT_ID = Account.id(1);
+ private Account account;
+ @Mock private VersionedAuthTokens versionedAuthTokens;
+ @Mock private VersionedAuthTokens.Factory authTokenFactory;
+ @Mock private AccountCache accountCache;
+ private HttpPasswordFallbackAuthTokenAccessor tokenAccessor;
+ private ImmutableList<AuthToken> tokens;
+
+ @Before
+ public void setUp() throws Exception {
+ tokenAccessor =
+ new HttpPasswordFallbackAuthTokenAccessor(
+ accountCache, new DirectAuthTokenAccessor(null, authTokenFactory, null, null));
+ tokens =
+ ImmutableList.of(
+ AuthToken.createWithPlainToken("id1", "hashedToken"),
+ AuthToken.createWithPlainToken("id2", "another_Token"));
+ doReturn(versionedAuthTokens).when(authTokenFactory).create(ACCOUNT_ID);
+ doReturn(versionedAuthTokens).when(versionedAuthTokens).load();
+ doReturn(tokens).when(versionedAuthTokens).getTokens();
+ account =
+ Account.builder(ACCOUNT_ID, Instant.EPOCH)
+ .setFullName("foo bar")
+ .setDisplayName("foo")
+ .setActive(true)
+ .setMetaId("dead..beef")
+ .setUniqueTag("dead..beef..tag")
+ .setStatus("OOO")
+ .setPreferredEmail("foo@bar.tld")
+ .build();
+ ;
+ doReturn(
+ AccountState.forAccount(
+ account,
+ ImmutableSet.of(
+ ExternalId.create(
+ ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo", false),
+ ACCOUNT_ID,
+ null,
+ "secret",
+ null))))
+ .when(accountCache)
+ .getEvenIfMissing(ACCOUNT_ID);
+ }
+
+ @Test
+ public void getTokensReturnsAuthTokens() {
+ assertThat(tokenAccessor.getTokens(ACCOUNT_ID)).containsExactlyElementsIn(tokens);
+ }
+
+ @Test
+ public void getTokensReturnsHttpPasswordIfNoAuthTokenExists() throws Exception {
+ doReturn(ImmutableList.of()).when(versionedAuthTokens).getTokens();
+ List<AuthToken> result = tokenAccessor.getTokens(ACCOUNT_ID);
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0))
+ .isEqualTo(AuthToken.create(DirectAuthTokenAccessor.LEGACY_ID, "secret"));
+ }
+
+ @Test
+ public void getTokensReturnsEmptyListIfNeitherTokensOrPasswordExists() throws Exception {
+ doReturn(ImmutableList.of()).when(versionedAuthTokens).getTokens();
+ doReturn(AccountState.forAccount(account)).when(accountCache).getEvenIfMissing(ACCOUNT_ID);
+ List<AuthToken> result = tokenAccessor.getTokens(ACCOUNT_ID);
+ assertThat(result).hasSize(0);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index a40afe8..8e74c44 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -59,7 +59,6 @@
ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com", false),
id,
"foo.bar@example.com",
- null,
ObjectId.fromString("1b9a0cf038ea38a0ab08617c39aa8e28413a27ca"));
ExternalId extId2 =
ExternalId.create(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 17a5796..e064399 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -19,6 +19,7 @@
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.NoMergeBaseReason;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
import com.google.gerrit.server.util.time.TimeUtil;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -782,8 +783,10 @@
+ "Subject: This is a test change\n"
+ "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ "Contains-Conflicts: true\n"
+ + "Base: 76ce7b5cb6f176fb64d9e4406f83fe5d658e1136\n"
+ "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
- + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n");
+ + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"
+ + "Merge-strategy: resolve\n");
// Conflicts information present, Contains-Conflicts: false
assertParseSucceeds(
@@ -795,10 +798,56 @@
+ "Subject: This is a test change\n"
+ "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ "Contains-Conflicts: false\n"
+ + "Base: 76ce7b5cb6f176fb64d9e4406f83fe5d658e1136\n"
+ "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
- + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n");
+ + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"
+ + "Merge-strategy: resolve\n");
- // Ours/Theirs is optional if "Contains-Conflicts: false" is set
+ // base not set, Conflicts information present, Contains-Conflicts: true
+ assertParseSucceeds(
+ "Update change\n"
+ + "\n"
+ + "Branch: refs/heads/master\n"
+ + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ + "Patch-set: 2\n"
+ + "Subject: This is a test change\n"
+ + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Contains-Conflicts: true\n"
+ + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
+ + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"
+ + "Merge-strategy: recursive\n"
+ + "No-base-reason: COMPUTED_BASE\n");
+
+ // base not set, Conflicts information present, Contains-Conflicts: false
+ assertParseSucceeds(
+ "Update change\n"
+ + "\n"
+ + "Branch: refs/heads/master\n"
+ + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ + "Patch-set: 2\n"
+ + "Subject: This is a test change\n"
+ + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Contains-Conflicts: false\n"
+ + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
+ + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"
+ + "Merge-strategy: ours\n"
+ + "No-base-reason: ONE_SIDED_MERGE_STRATEGY\n");
+
+ // Base/Ours/Theirs/Merge-strategy is optional if "Contains-Conflicts: false" is set.
+ assertParseSucceeds(
+ "Update change\n"
+ + "\n"
+ + "Branch: refs/heads/master\n"
+ + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ + "Patch-set: 2\n"
+ + "Subject: This is a test change\n"
+ + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Contains-Conflicts: false\n"
+ + "No-base-reason: NO_MERGE_PERFORMED\n");
+
+ // Base/Ours/Theirs/Merge-strategy is optional if "Contains-Conflicts: false" is set.
+ // No-base-reason is missing for revisions that have been created before Gerrit started to store
+ // the base in the conflicts information.
assertParseSucceeds(
"Update change\n"
+ "\n"
@@ -809,7 +858,26 @@
+ "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ "Contains-Conflicts: false\n");
- // Ours/Theirs is ignored if Contains-Conflicts is missing
+ // Base/Ours/Theirs/Merge-strategy/No-base-reason is optional if "Contains-Conflicts: true" is
+ // set.
+ // The data is missing for revisions that have been created before Gerrit started to store
+ // the base in the conflicts information.
+ ChangeNotesState changeNotesState =
+ assertParseSucceeds(
+ "Update change\n"
+ + "\n"
+ + "Branch: refs/heads/master\n"
+ + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ + "Patch-set: 2\n"
+ + "Subject: This is a test change\n"
+ + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Contains-Conflicts: true\n"
+ + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
+ + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n");
+ assertThat(changeNotesState.patchSets().getLast().getValue().conflicts().get().noBaseReason())
+ .hasValue(NoMergeBaseReason.HISTORIC_DATA_WITHOUT_BASE);
+
+ // Base/Ours/Theirs/Merge-strategy/No-base-reason is ignored if Contains-Conflicts is missing
assertParseSucceeds(
"Update change\n"
+ "\n"
@@ -818,6 +886,25 @@
+ "Patch-set: 2\n"
+ "Subject: This is a test change\n"
+ "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Base: 76ce7b5cb6f176fb64d9e4406f83fe5d658e1136\n"
+ + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
+ + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"
+ + "Merge-strategy: resolve\n"
+ + "No-base-reason: COMPUTED_BASE\n");
+
+ // Base/Merge-Strategy/No-base-reason is missing when conflicts information is present,
+ // Contains-Conflicts: true
+ // Base/Merge-Strategy/No-base-reason is missing for patch sets that have been created before
+ // Gerrit started to store the base for conflicts.
+ assertParseSucceeds(
+ "Update change\n"
+ + "\n"
+ + "Branch: refs/heads/master\n"
+ + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ + "Patch-set: 2\n"
+ + "Subject: This is a test change\n"
+ + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Contains-Conflicts: true\n"
+ "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"
+ "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n");
@@ -851,6 +938,18 @@
+ "Subject: This is a test change\n"
+ "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ "Contains-Conflicts: true\n");
+
+ // Parsing fails if No-base-reason is invalid
+ assertParseFails(
+ "Update change\n"
+ + "\n"
+ + "Branch: refs/heads/master\n"
+ + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ + "Patch-set: 2\n"
+ + "Subject: This is a test change\n"
+ + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+ + "Contains-Conflicts: false\n"
+ + "No-base-reason: INVALID\n");
}
private RevCommit writeCommit(String body) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
index 366cbf7..b9f8308 100644
--- a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
@@ -187,7 +187,7 @@
ExternalId.Key.create(
ExternalId.SCHEME_IMPORTED, importedAccount + "@" + IMPORTED_SERVER_ID, false);
ExternalId linkedExternalId =
- ExternalId.create(importedAccountIdKey, localAccountId, null, null, null);
+ ExternalId.create(importedAccountIdKey, localAccountId, null, null);
when(externalIdCacheMock.byKey(eq(importedAccountIdKey)))
.thenReturn(Optional.of(linkedExternalId));
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index ab1bc8e..6954ba2 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -4696,7 +4696,10 @@
}
protected void createProject(Project.NameKey project) throws Exception {
- gApi.projects().create(project.get());
+ ProjectInput projectInput = new ProjectInput();
+ projectInput.name = project.get();
+ projectInput.createEmptyCommit = true;
+ gApi.projects().create(projectInput);
}
protected void createProject(Project.NameKey project, Project.NameKey parent) throws Exception {
diff --git a/package.json b/package.json
index c7ee023..774ed99 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
"eslintfix:modified": "git diff --name-only --diff-filter=d | grep -E 'polygerrit-ui/app/.*\\.(js|ts)$' | sed 's|^polygerrit-ui/app/||' | xargs -r npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix",
"litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
+ "litlintforCI": "lit-analyzer --strict --rules.no-unknown-property off --rules.no-unknown-tag-name off --rules.no-incompatible-type-binding off --rules.no-incompatible-property-type off --rules.no-invalid-tag-name off --rules.no-property-visibility-mismatch off --rules.no-unknown-attribute off **/elements/**/*.ts",
"lint": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app",
"gjf": "./tools/gjf.sh run"
},
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 9da46c0..ac51959 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -176,6 +176,26 @@
yarn test:single "**/gr-comment_test.ts"
```
+### Screenshot Tests
+
+We use screenshot tests to prevent unintended visual regressions.
+
+To run the screenshot tests:
+```sh
+yarn test:screenshot
+```
+
+If a test fails, it means the component's appearance has changed. New screenshots will be generated in the `polygerrit-ui/screenshots/Chromium/failed/` directory. In case of a mismatch with an existing baseline, a diff image will also be created there.
+
+If the change is intended, you need to approve the new screenshots as the baseline. To do this, move the new screenshot files from the `failed` directory to the `baseline` directory, overwriting the old ones. The diff images in the `failed` directory can be deleted.
+
+```sh
+# Move all failed screenshots at once:
+mv polygerrit-ui/screenshots/Chromium/failed/*.png polygerrit-ui/screenshots/Chromium/baseline/
+```
+
+After moving the file(s), run `yarn test:screenshot` again to confirm that they pass.
+
Compiling code:
```sh
# Compile frontend once to check for type errors:
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index d7a132f..0d374a4 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -324,6 +324,7 @@
edit_full_name_url?: string;
http_password_url?: string;
git_basic_auth_policy?: string;
+ max_token_lifetime?: string;
}
/**
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 9db6ab6..a60f960 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -166,4 +166,6 @@
COMMENT_COMPLETION_SUGGESTION_ACCEPTED = 'comment-completion-suggestion-accepted',
COMMENT_COMPLETION_SAVE_DRAFT = 'comment-completion-save-draft',
COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched',
+ GENERATE_SUGGESTION_THUMB_UP = 'ai_suggestion_thumb_up',
+ GENERATE_SUGGESTION_THUMB_DOWN = 'ai_suggestion_thumb_down',
}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index 9c39f6b..37df2d8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -6,9 +6,9 @@
import '../../shared/gr-account-label/gr-account-label';
import {
AccountInfo,
- EncodedGroupId,
GroupAuditEventInfo,
GroupAuditGroupEventInfo,
+ GroupId,
GroupInfo,
isGroupAuditGroupEventInfo,
} from '../../../types/common';
@@ -30,7 +30,7 @@
@customElement('gr-group-audit-log')
export class GrGroupAuditLog extends LitElement {
@property({type: String})
- groupId?: EncodedGroupId;
+ groupId?: GroupId;
@state() private auditLog?: GroupAuditEventInfo[];
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 a565875..17d2376 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
@@ -12,8 +12,8 @@
} from '../../../test/test-utils';
import {GrGroupAuditLog} from './gr-group-audit-log';
import {
- EncodedGroupId,
GroupAuditEventType,
+ GroupId,
GroupInfo,
GroupName,
} from '../../../types/common';
@@ -111,7 +111,7 @@
suite('404', () => {
test('fires page-error', async () => {
- element.groupId = '1' as EncodedGroupId;
+ element.groupId = '1' as GroupId;
await element.updateComplete;
const response = {...new Response(), status: 404};
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index d2dbc3c..4d62e20 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -191,8 +191,8 @@
<span class="value">
<gr-autocomplete
id="groupMemberSearchInput"
- .text=${this.groupMemberSearchName}
- .value=${this.groupMemberSearchId}
+ .text=${this.groupMemberSearchName ?? ''}
+ .value=${`${this.groupMemberSearchId ?? ''}`}
.query=${this.queryMembers}
placeholder="Name Or Email"
@text-changed=${this.handleGroupMemberTextChanged}
@@ -227,8 +227,8 @@
<span class="value">
<gr-autocomplete
id="includedGroupSearchInput"
- .text=${this.includedGroupSearchName}
- .value=${this.includedGroupSearchId}
+ .text=${this.includedGroupSearchName ?? ''}
+ .value=${this.includedGroupSearchId ?? ''}
.query=${this.queryIncludedGroup}
placeholder="Group Name"
@text-changed=${this.handleIncludedGroupTextChanged}
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 7543162..9b9fa6f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -159,7 +159,7 @@
<span class="value">
<gr-autocomplete
id="groupNameInput"
- .text=${this.groupConfig?.name}
+ .text=${this.groupConfig?.name ?? ''}
?disabled=${this.computeGroupDisabled()}
@text-changed=${this.handleNameTextChanged}
></gr-autocomplete>
@@ -191,8 +191,8 @@
<span class="value">
<gr-autocomplete
id="groupOwnerInput"
- .text=${this.groupConfig?.owner}
- .value=${this.groupConfigOwner}
+ .text=${this.groupConfig?.owner ?? ''}
+ .value=${this.groupConfigOwner ?? ''}
.query=${this.query}
?disabled=${this.computeGroupDisabled()}
@text-changed=${this.handleOwnerTextChanged}
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 fb05bc3..fe1f20b 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -229,7 +229,7 @@
() => html`
<md-switch
id="exclusiveToggle"
- ?selected=${this.permission?.value.exclusive}
+ ?selected=${!!this.permission?.value.exclusive}
?disabled=${!this.editing}
@change=${this.handleValueChange}
></md-switch
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index c1f3e4a..70a7d10 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -202,7 +202,7 @@
>
<gr-autocomplete
id="editInheritFromInput"
- .text=${this.inheritFromFilter}
+ .text=${this.inheritFromFilter ?? ''}
.query=${this.query}
@commit=${(e: AutocompleteCommitEvent) => {
this.handleUpdateInheritFrom(e);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
index 52e6f3c..4cafb93 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
@@ -423,7 +423,7 @@
<span class="value">
<textarea
id="description"
- .value=${this.newRequirement.description}
+ .value=${this.newRequirement.description ?? ''}
placeholder="Optional"
></textarea>
</span>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 45f6df8..3e7e3fd 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -48,6 +48,7 @@
import {resolve} from '../../../models/dependency';
import {GrButton} from '../../shared/gr-button/gr-button';
import {KnownExperimentId} from '../../../services/flags/flags';
+import {Command} from '../../shared/gr-download-commands/gr-download-commands';
const STATES = {
active: {value: RepoState.ACTIVE, label: 'Active'},
@@ -1138,9 +1139,9 @@
return schemeInfo?.description;
}
- private computeCommands() {
+ private computeCommands(): Command[] {
const schemeInfo = this.getSchemeInfo();
- if (!this.repo || !schemeInfo) return undefined;
+ if (!this.repo || !schemeInfo) return [];
const commandObj = schemeInfo.clone_commands ?? {};
const commands = [];
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
index 15bfed5..1ff6a79 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -143,7 +143,7 @@
<gr-icon
class=${icon.icon}
icon=${icon.icon}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
></gr-icon>
`;
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
index 31e409a..9e24d0c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -102,7 +102,7 @@
<gr-icon
class=${icon.icon}
icon=${icon.icon}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
role="img"
></gr-icon>
${aggregation}</span
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index c290e9a..56fddb2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -25,6 +25,7 @@
NumericChangeId,
ServerInfo,
Timestamp,
+ UserId,
} from '../../../types/common';
import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -82,7 +83,7 @@
* generated.
*/
@property({type: String})
- dashboardUser?: string;
+ dashboardUser?: UserId | 'self';
@property({type: Array})
visibleChangeTableColumns?: string[];
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 6b4fd4e..a802428 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -25,6 +25,7 @@
import {subscribe} from '../../lit/subscription-controller';
import {classMap} from 'lit/directives/class-map.js';
import {formStyles} from '../../../styles/form-styles';
+import {UserId} from '../../../types/common';
const NUMBER_FIXED_COLUMNS = 4;
const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
@@ -83,7 +84,7 @@
* generated.
*/
@property({type: String})
- dashboardUser?: string;
+ dashboardUser?: UserId | 'self';
@property({type: String})
usp?: string;
@@ -205,7 +206,7 @@
<td class="leftPadding" aria-hidden="true"></td>
<td
class="star"
- ?aria-hidden=${!this.isLoggedIn}
+ aria-hidden=${!this.isLoggedIn}
?hidden=${!this.isLoggedIn}
></td>
<td class="cell" colspan=${colSpan}>
@@ -337,7 +338,7 @@
.change=${change}
.sectionName=${this.changeSection.name}
.visibleChangeTableColumns=${columns}
- .showNumber=${this.showNumber}
+ .showNumber=${!!this.showNumber}
.usp=${this.usp}
.labelNames=${this.labelNames}
.globalIndex=${this.startIndex + index}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 051ac91..933d933 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -8,10 +8,9 @@
import '../gr-user-header/gr-user-header';
import {
AccountDetailInfo,
- AccountId,
ChangeInfo,
- EmailAddress,
RepoName,
+ UserId,
} from '../../../types/common';
import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
import {fire, fireAlert, fireTitleChange} from '../../../utils/event-util';
@@ -62,7 +61,7 @@
@state() loading = true;
// private but used in test
- @state() userId?: AccountId | EmailAddress;
+ @state() userId?: UserId;
// private but used in test
@state() repo?: RepoName;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index c2faa0c..061bd3d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -15,6 +15,7 @@
ChangeInfo,
PreferencesInput,
ServerInfo,
+ UserId,
} from '../../../types/common';
import {fire, fireReload} from '../../../utils/event-util';
import {ColumnNames, ScrollMode} from '../../../constants/constants';
@@ -95,7 +96,7 @@
* generated.
*/
@property({type: String})
- dashboardUser?: string;
+ dashboardUser?: UserId | 'self';
@property({type: Array})
changes?: ChangeInfo[];
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_screenshot_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_screenshot_test.ts
new file mode 100644
index 0000000..dee4356
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_screenshot_test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-change-list';
+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 {GrChangeList} from './gr-change-list';
+import {createChange} from '../../../test/test-data-generators';
+import {ChangeInfo, NumericChangeId, Timestamp} from '../../../types/common';
+import {visualDiffDarkTheme} from '../../../test/test-utils';
+
+suite('gr-change-list screenshot tests', () => {
+ let element: GrChangeList;
+
+ function createChanges(count: number): ChangeInfo[] {
+ return Array.from(Array(count).keys()).map(index => {
+ return {
+ ...createChange(),
+ _number: (index + 1) as NumericChangeId,
+ subject: `Change subject ${index + 1}`,
+ updated: `2020-01-${String(index + 1).padStart(
+ 2,
+ '0'
+ )} 10:00:00.000000000` as Timestamp,
+ };
+ });
+ }
+
+ setup(async () => {
+ element = await fixture(html`<gr-change-list></gr-change-list>`);
+ element.changes = createChanges(5);
+ await element.updateComplete;
+ });
+
+ test('basic list', async () => {
+ await visualDiff(element, 'gr-change-list');
+ await visualDiffDarkTheme(element, 'gr-change-list');
+ });
+});
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 b7b9131..cb7f42f 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
@@ -20,6 +20,7 @@
DashboardId,
PreferencesInput,
RepoName,
+ UserId,
} from '../../../types/common';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
@@ -376,7 +377,7 @@
}
// private but used in test
- computeTitle(user?: string) {
+ computeTitle(user?: UserId | 'self') {
if (!user || user === 'self') {
return 'My Reviews';
}
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 9736d7a..22816a8 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
@@ -28,6 +28,7 @@
import {
ChangeInfoId,
DashboardId,
+ EmailAddress,
RepoName,
Timestamp,
} from '../../../types/common';
@@ -124,7 +125,7 @@
element.viewState = {
view: GerritView.DASHBOARD,
type: DashboardType.CUSTOM,
- user: 'user',
+ user: 'user@email.com' as EmailAddress,
sections: [
{name: 'test1', query: 'test1', hideIfEmpty: true},
{name: 'test2', query: 'test2', hideIfEmpty: true},
@@ -176,7 +177,7 @@
element.viewState = {
view: GerritView.DASHBOARD,
type: DashboardType.USER,
- user: 'notself',
+ user: 'notself@email.com' as EmailAddress,
dashboard: '' as DashboardId,
};
element.maybeShowDraftsBanner();
@@ -300,7 +301,10 @@
test('computeTitle', () => {
assert.equal(element.computeTitle('self'), 'My Reviews');
- assert.equal(element.computeTitle('not self'), 'Dashboard for not self');
+ assert.equal(
+ element.computeTitle('notself@email.com' as EmailAddress),
+ 'Dashboard for notself@email.com'
+ );
});
suite('computeSectionCountLabel', () => {
@@ -368,7 +372,7 @@
element.viewState = {
view: GerritView.DASHBOARD,
type: DashboardType.CUSTOM,
- user: 'user',
+ user: 'user@email.com' as EmailAddress,
dashboard: '' as DashboardId,
sections: [
{name: '', query: '1'},
@@ -592,7 +596,7 @@
view: GerritView.DASHBOARD,
type: DashboardType.USER,
dashboard: '' as DashboardId,
- user: 'user',
+ user: 'user@email.com' as EmailAddress,
};
await element.updateComplete;
assert.isOk(query(element, 'gr-user-header'));
@@ -602,7 +606,7 @@
type: DashboardType.REPO,
dashboard: '' as DashboardId,
project: 'p' as RepoName,
- user: 'user',
+ user: 'user@email.com' as EmailAddress,
};
await element.updateComplete;
assert.isNotOk(query(element, 'gr-user-header'));
@@ -628,7 +632,7 @@
type: DashboardType.REPO,
dashboard: 'dashboard' as DashboardId,
project: 'project' as RepoName,
- user: '',
+ user: undefined,
};
await Promise.all([element.reload(), promise]);
});
@@ -666,7 +670,7 @@
type: DashboardType.REPO,
dashboard: 'dashboard' as DashboardId,
project: 'project' as RepoName,
- user: '',
+ user: undefined,
};
await element.reload();
assert.isFalse(dashboardDisplayedStub.calledOnce);
@@ -678,7 +682,7 @@
element.viewState = {
view: GerritView.DASHBOARD,
type: DashboardType.USER,
- user: 'notself',
+ user: 'notself@email.com' as EmailAddress,
};
await element.reload();
assert.isFalse(dashboardDisplayedStub.calledOnce);
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index a09c9d2..1836f48 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -7,7 +7,7 @@
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../shared/gr-avatar/gr-avatar';
import '../../shared/gr-date-formatter/gr-date-formatter';
-import {AccountDetailInfo, AccountId} from '../../../types/common';
+import {AccountDetailInfo, UserId} from '../../../types/common';
import {getDisplayName} from '../../../utils/display-name-util';
import {getAppContext} from '../../../services/app-context';
import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
@@ -23,7 +23,7 @@
@customElement('gr-user-header')
export class GrUserHeader extends LitElement {
@property({type: String})
- userId?: AccountId;
+ userId?: UserId;
@property({type: Boolean})
showDashboardLink = false;
@@ -113,7 +113,7 @@
}
}
- _accountChanged(userId?: AccountId) {
+ _accountChanged(userId?: UserId) {
if (!userId) {
this._accountDetails = undefined;
this._status = '';
@@ -148,8 +148,7 @@
if (!accountDetails) return '';
const id = accountDetails._account_id;
- if (id)
- return createDashboardUrl({type: DashboardType.USER, user: String(id)});
+ if (id) return createDashboardUrl({type: DashboardType.USER, user: id});
const email = accountDetails.email;
if (email)
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 1530b4f..efb9243 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
@@ -776,7 +776,7 @@
<gr-create-change-dialog
id="createFollowUpChange"
.branch=${this.change?.branch}
- .baseChange=${this.change?.id}
+ .baseChange=${this.change?.change_id}
.repoName=${this.change?.project}
.privateByDefault=${this.privateByDefault}
></gr-create-change-dialog>
@@ -858,7 +858,7 @@
private renderUIActionIcon(action: UIActionInfo) {
if (!action.icon) return nothing;
return html`
- <gr-icon icon=${action.icon} ?filled=${action.filled}></gr-icon>
+ <gr-icon icon=${action.icon} ?filled=${!!action.filled}></gr-icon>
`;
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 4dbc653..2ffd27f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -166,9 +166,11 @@
@state() showAllSections = false;
- @state() queryTopic?: AutocompleteQuery;
+ @state() queryIdentity: AutocompleteQuery;
- @state() queryHashtag?: AutocompleteQuery;
+ @state() queryTopic: AutocompleteQuery;
+
+ @state() queryHashtag: AutocompleteQuery;
private restApiService = getAppContext().restApiService;
@@ -224,6 +226,7 @@
);
this.queryTopic = (input: string) => this.getTopicSuggestions(input);
this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
+ this.queryIdentity = (input: string) => this.getIdentitySuggestions(input);
}
static override get styles() {
@@ -499,7 +502,7 @@
this.handleIdentityChanged(e, role)}
showAsEditPencil
autocomplete
- .query=${(text: string) => this.getIdentitySuggestions(text)}
+ .query=${this.queryIdentity}
></gr-editable-label>
`
)}
@@ -660,7 +663,7 @@
${when(
this.showTopicChip(),
() => html` <gr-linked-chip
- .text=${this.change?.topic}
+ .text=${this.change?.topic ?? ''}
limit="40"
href=${createSearchUrl({topic: this.change!.topic!})}
?removable=${!this.topicReadOnly}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
index 8bfb877..1315385 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
@@ -190,7 +190,7 @@
private renderChip(clazz: string, ariaLabel: string, icon: ChecksIcon) {
return html`
<div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
- <gr-icon icon=${icon.name} ?filled=${icon.filled}></gr-icon>
+ <gr-icon icon=${icon.name} ?filled=${!!icon.filled}></gr-icon>
${this.renderLinks()}
<div class="text">${this.text}</div>
</div>
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 898a2b6..3c43d0d 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
@@ -520,7 +520,13 @@
);
this.shortcutsController.addAbstract(
Shortcut.OPEN_COPY_LINKS_DROPDOWN,
- () => this.copyLinksDropdown?.openDropdown()
+ () => {
+ const button = this.shadowRoot?.querySelector<HTMLElement>(
+ '#copyLinkDialogButton'
+ );
+ if (!button) return;
+ this.copyLinksDropdown?.openDropdown(button);
+ }
);
}
@@ -1099,7 +1105,7 @@
private renderMainContent() {
return html`
- <div id="mainContent" class="container" ?hidden=${this.loading}>
+ <div id="mainContent" class="container" ?hidden=${!!this.loading}>
${this.renderChangeInfoSection()}
<h2 class="assistive-tech-only">Files and Comments tabs</h2>
${this.renderTabHeaders()} ${this.renderTabContent()}
@@ -1414,7 +1420,7 @@
.changeUrl=${this.computeChangeUrl()}
.editMode=${this.editMode}
.loggedIn=${this.loggedIn}
- .shownFileCount=${this.shownFileCount}
+ .shownFileCount=${this.shownFileCount ?? 0}
.filesExpanded=${this.fileList?.filesExpanded}
@open-diff-prefs=${this.handleOpenDiffPrefs}
@open-download-dialog=${this.handleOpenDownloadDialog}
@@ -1451,7 +1457,7 @@
return html`
<h3 class="assistive-tech-only">Comments</h3>
<gr-thread-list
- .threads=${this.commentThreads}
+ .threads=${this.commentThreads ?? []}
.commentTabState=${this.tabState}
.unresolvedOnly=${this.unresolvedOnly}
.scrollCommentId=${this.scrollCommentId}
@@ -2180,6 +2186,14 @@
}
private startUpdateCheckTimer() {
+ // Check if there are change updates provider already configured
+ // If yes, then polling for new updates is not required as the provider
+ // will send a signal instead.
+ const updateProviders =
+ this.getPluginLoader().pluginsModel.getChangeUpdatesPlugins();
+ if (updateProviders.length > 0) {
+ return;
+ }
const delay = this.serverConfig?.change?.update_delay ?? 0;
if (delay <= MIN_CHECK_INTERVAL_SECS) return;
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 c8e0948..b15a4e4 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
@@ -369,7 +369,7 @@
() => html`<div id="cherryPickEmailDropdown">Cherry Pick Committer Email
<gr-dropdown-list
.items=${this.getEmailDropdownItems()}
- .value=${this.committerEmail}
+ .value=${this.committerEmail ?? ''}
@value-change=${this.setCommitterEmail}
>
</gr-dropdown-list>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 4c1109f..a6aa12c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -378,7 +378,7 @@
>Rebase with committer email
<gr-dropdown-list
.items=${this.getCommitterEmailDropdownItems()}
- .value=${this.selectedEmailForRebase}
+ .value=${this.selectedEmailForRebase ?? ''}
@value-change=${this.handleCommitterEmailDropdownItems}
>
</gr-dropdown-list>
@@ -544,8 +544,8 @@
return this.hasParent && !this.rebaseOnCurrent;
}
- private displayTipOption() {
- return this.rebaseOnCurrent || this.hasParent;
+ private displayTipOption(): boolean {
+ return !!this.rebaseOnCurrent || !!this.hasParent;
}
/**
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 72628a1..d049f0c 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
@@ -147,7 +147,7 @@
shortcut=${`${this.shortcutPrefix}${shortcut}`}
id=${`${id}-copy-clipboard`}
nowrap
- ?multiline=${multiline}
+ ?multiline=${!!multiline}
${index === 0 && ref(this.copyClipboardRef)}
></gr-copy-clipboard>
</div>`;
@@ -171,7 +171,10 @@
this.dropdown?.close();
}
- openDropdown() {
+ openDropdown(button?: HTMLElement) {
+ if (button) {
+ this.dropdown!.anchorElement = button;
+ }
this.dropdown?.show();
this.awaitOpen(() => {
if (!this.copyClipboardRef?.value) return;
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 329e121..40d5da5 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
@@ -1405,7 +1405,7 @@
class="removed"
tabindex="0"
aria-label=${`${file.lines_deleted} removed`}
- ?hidden=${file.binary}
+ ?hidden=${!!file.binary}
>
-${file.lines_deleted}
</span>
@@ -1413,7 +1413,7 @@
class="added"
tabindex="0"
aria-label=${`${file.lines_inserted} added`}
- ?hidden=${file.binary}
+ ?hidden=${!!file.binary}
>
+${file.lines_inserted}
</span>
@@ -1751,7 +1751,10 @@
icons =>
html`
<div class="checkChip ${icons[0].name}">
- <gr-icon icon=${icons[0].name} ?filled=${icons[0].filled}></gr-icon>
+ <gr-icon
+ icon=${icons[0].name}
+ ?filled=${!!icons[0].filled}
+ ></gr-icon>
<div>${icons.length}</div>
</div>
`
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index fa56c49..af2e48c 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -12,7 +12,6 @@
import {getAppContext} from '../../../services/app-context';
import {customElement, property, state} from 'lit/decorators.js';
import {
- ChangeId,
ChangeMessageId,
ChangeMessageInfo,
CommentThread,
@@ -303,7 +302,7 @@
private change?: ParsedChangeInfo;
@state()
- private changeNum?: ChangeId | NumericChangeId;
+ private changeNum?: NumericChangeId;
@state()
private commentThreads: CommentThread[] = [];
@@ -376,7 +375,7 @@
.change=${this.change}
.changeNum=${this.changeNum}
.message=${message}
- .commentThreads=${message.commentThreads}
+ .commentThreads=${message.commentThreads ?? []}
@message-anchor-tap=${this.handleAnchorClick}
.labelExtremes=${labelExtremes}
data-message-id=${ifDefined(getMessageId(message) as string)}
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 cc33f5de..b299c17 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
@@ -944,7 +944,7 @@
// See `addReplyTextChangedCallback` in `ChangeReplyPluginApi`.
fire(e.currentTarget as HTMLElement, 'value-changed', e.detail);
}}
- .messagePlaceholder=${this.messagePlaceholder}
+ .messagePlaceholder=${this.messagePlaceholder ?? ''}
hide-header
permanent-editing-mode
></gr-comment>
@@ -1240,7 +1240,7 @@
<gr-button
id="sendButton"
primary
- ?disabled=${this.isSendDisabled()}
+ ?disabled=${!!this.isSendDisabled()}
class="action send"
@click=${this.sendClickHandler}
>${this.canBeStarted
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 950fb01..e94d459 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
@@ -165,7 +165,7 @@
return html`<gr-icon
class=${icon.icon}
icon=${icon.icon}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
role="img"
aria-label=${requirement.status.toLowerCase()}
></gr-icon>`;
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 0bfe9af..5907624 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
@@ -252,7 +252,7 @@
const icon = iconForRequirement(requirement);
return html`<gr-icon
class=${icon.icon}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
.icon=${icon.icon}
role="img"
aria-label=${requirement.status.toLowerCase()}
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 5e8331c..95271f5 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -404,7 +404,7 @@
<gr-comment-thread
.thread=${thread}
show-file-path
- ?show-ported-comment=${thread.ported}
+ ?show-ported-comment=${!!thread.ported}
?show-comment-context=${this.showCommentContext}
?show-file-name=${isFirst}
.messageId=${this.messageId}
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
index 7365cc9..e400584 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -91,7 +91,7 @@
}
private renderHovercard() {
- if (this.disableHovercards) return;
+ if (this.disableHovercards || !this.label) return;
return html`<gr-trigger-vote-hovercard
.labelName=${this.label}
.labelInfo=${this.labelInfo}
@@ -100,7 +100,7 @@
slot="label-info"
.change=${this.change}
.account=${this.account}
- .mutable=${this.mutable}
+ .mutable=${!!this.mutable}
.label=${this.label}
.labelInfo=${this.labelInfo}
.showAllReviewers=${false}
@@ -127,7 +127,7 @@
></gr-vote-chip>`
);
} else if (isQuickLabelInfo(labelInfo)) {
- return [html`<gr-vote-chip .label=${this.labelInfo}></gr-vote-chip>`];
+ return [html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`];
} else {
return html``;
}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 9fcf815..720832f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -97,7 +97,7 @@
return html`
<gr-button
link
- ?disabled=${this.action.disabled}
+ ?disabled=${!!this.action.disabled}
class="action"
@click=${(e: Event) => this.handleClick(e)}
>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 84151c1..81cb2c9 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -606,7 +606,7 @@
>
<gr-icon
icon=${icon.name}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
aria-label="external link to details"
class="link"
></gr-icon>
@@ -923,7 +923,11 @@
target=${ifDefined(target)}
rel="noopener noreferrer"
>
- <gr-icon icon=${icon.name} class="link" ?filled=${icon.filled}></gr-icon>
+ <gr-icon
+ icon=${icon.name}
+ class="link"
+ ?filled=${!!icon.filled}
+ ></gr-icon>
<span>${text}</span>
</a>`;
}
@@ -1363,7 +1367,7 @@
)
.slice(0, 4);
const overflowLinks = links.filter(a => !primaryLinks.includes(a));
- const overflowLinkItems = overflowLinks.map(link => {
+ const overflowLinkItems: DropdownLink[] = overflowLinks.map(link => {
return {
...link,
id: link.tooltip,
@@ -1410,7 +1414,7 @@
icon=${icon.name}
aria-label=${tooltipText}
class="link"
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
></gr-icon>
</a>
</gr-tooltip-content>`;
@@ -1616,7 +1620,7 @@
<div class="statusIconWrapper">
<gr-icon
icon=${icon.name}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
class="statusIcon ${catString}"
></gr-icon>
<span class="title">${catString}</span>
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
new file mode 100644
index 0000000..9838604
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_screenshot_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import './gr-checks-results';
+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 {checksModelToken} from '../../models/checks/checks-model';
+import {setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
+import {GrChecksResults} from './gr-checks-results';
+import {visualDiffDarkTheme} from '../../test/test-utils';
+
+suite('gr-checks-results screenshot', () => {
+ let element: GrChecksResults;
+
+ setup(async () => {
+ element = await fixture<GrChecksResults>(
+ html`<gr-checks-results></gr-checks-results>`
+ );
+ const getChecksModel = resolve(element, checksModelToken);
+ getChecksModel().allRunsSelectedPatchset$.subscribe(
+ runs => (element.runs = runs)
+ );
+ setAllFakeRuns(getChecksModel());
+ await element.updateComplete;
+ });
+
+ test('screenshot', async () => {
+ await visualDiff(element, 'gr-checks-results');
+ await visualDiffDarkTheme(element, 'gr-checks-results');
+ });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 0576fb6..e611d42 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -266,7 +266,7 @@
<gr-icon
class=${icon.name}
icon=${icon.name}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
></gr-icon>
${this.renderAdditionalIcon()}
<span class="name">${this.run.checkName}</span>
@@ -316,7 +316,7 @@
r.checkName === this.run?.checkName &&
r.pluginName === this.run?.pluginName
)}
- .attempt=${attempt}
+ .attempt=${attempt as number}
></gr-hovercard-run>`
)}
<input
@@ -330,7 +330,7 @@
<gr-icon
icon=${icon.name}
class=${icon.name}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
></gr-icon>
<label for=${id}>
${attemptChoiceLabel(attempt)}${wasNotRun ? ' (not run)' : ''}
@@ -402,7 +402,7 @@
<gr-icon
icon=${icon.name}
class=${icon.name}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
></gr-icon>
`;
}
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 c41cf8f..a57e22f 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -193,7 +193,7 @@
<div class="${cat} container font-normal">
<div class="header" @click=${this.toggleExpandedClick}>
<div class="icon">
- <gr-icon icon=${icon.name} ?filled=${icon.filled}></gr-icon>
+ <gr-icon icon=${icon.name} ?filled=${!!icon.filled}></gr-icon>
</div>
<div class="name">
<gr-hovercard-run .run=${this.result}></gr-hovercard-run>
@@ -314,7 +314,6 @@
}
private renderPleaseFixButton() {
- if (this.isOwner) return nothing;
const action: Action = {
name: 'Please Fix',
callback: () => {
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
new file mode 100644
index 0000000..0e6e9e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_screenshot_test.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2022 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 {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 {RunResult} from '../../models/checks/checks-model';
+
+suite('gr-diff-check-result screenshot tests', () => {
+ let element: GrDiffCheckResult;
+
+ setup(async () => {
+ element = await fixture(
+ html`<gr-diff-check-result></gr-diff-check-result>`
+ );
+ });
+
+ test('collapsed', async () => {
+ element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult;
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-diff-check-result-collapsed');
+ await visualDiffDarkTheme(element, 'gr-diff-check-result-collapsed');
+ });
+
+ test('expanded', async () => {
+ element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+ element.isExpanded = true;
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-diff-check-result-expanded');
+ await visualDiffDarkTheme(element, 'gr-diff-check-result-expanded');
+ });
+});
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 b7af02f..6ef8a25 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
@@ -128,6 +128,15 @@
`
);
});
+
+ test('shows please-fix button for author', async () => {
+ element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult;
+ element.isOwner = true;
+ await element.updateComplete;
+ const button = queryAndAssert(element, '#please-fix');
+ assert.isOk(button);
+ });
+
suite('AI fix button', () => {
setup(async () => {
element.result = {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index b38a30e..05b5dc6 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -133,7 +133,7 @@
<div class="chip">
<gr-icon
icon=${chipIcon.name}
- ?filled=${chipIcon.filled}
+ ?filled=${!!chipIcon.filled}
></gr-icon>
<span>${this.run.status}</span>
</div>
@@ -144,7 +144,7 @@
<gr-icon
icon=${icon.name}
class=${icon.name}
- ?filled=${icon.filled}
+ ?filled=${!!icon.filled}
></gr-icon>
</div>
<div class="sectionContent">
@@ -229,7 +229,11 @@
return html`
<div>
<div class="attemptIcon">
- <gr-icon class=${icon.name} icon=${icon.name} ?filled=${icon.filled}>
+ <gr-icon
+ class=${icon.name}
+ icon=${icon.name}
+ ?filled=${!!icon.filled}
+ >
</gr-icon>
</div>
<div class="attemptNumber">${ordinal(attemptNumber)}</div>
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 f1a3182..6c45922 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
@@ -92,7 +92,10 @@
];
// visible for testing
-export function getDocLinks(docBaseUrl: string, docLinks: MainHeaderLink[]) {
+export function getDocLinks(
+ docBaseUrl: string,
+ docLinks: MainHeaderLink[]
+): MainHeaderLink[] {
if (!docBaseUrl) return [];
return docLinks.map(link => {
return {
@@ -825,7 +828,7 @@
return html`
<gr-account-dropdown
.account=${this.account}
- ?showMobile=${showOnMobile}
+ ?showMobile=${!!showOnMobile}
@click=${() => {
if (this.hamburgerClose) {
this.handleSidebar();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 37bdfff..e84a2dc 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -18,7 +18,11 @@
createServerInfo,
} from '../../../test/test-data-generators';
import {NavLink} from '../../../models/views/admin';
-import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
+import {
+ ServerInfo,
+ TopMenuEntryInfo,
+ TopMenuItemInfo,
+} from '../../../types/common';
import {AuthType} from '../../../constants/constants';
import {assert, fixture, html} from '@open-wc/testing';
@@ -278,16 +282,14 @@
});
test('fix my menu item', () => {
- assert.deepEqual(
- [
- {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
- {url: '#/q/is:nice', name: '', target: '_blank'},
- ].map(element.createHeaderLink),
- [
- {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
- {url: '/q/is:nice', name: '', target: '_blank'},
- ]
- );
+ const menuLink: TopMenuItemInfo[] = [
+ {url: 'https://awesometown.com/#hashyhash', name: '', target: undefined},
+ {url: '#/q/is:nice', name: '', target: '_blank'},
+ ];
+ assert.deepEqual(menuLink.map(element.createHeaderLink), [
+ {url: 'https://awesometown.com/#hashyhash', name: '', target: undefined},
+ {url: '/q/is:nice', name: '', target: '_blank'},
+ ]);
});
test('user links', () => {
@@ -306,7 +308,7 @@
{
name: 'Facebook',
url: 'https://facebook.com',
- target: '',
+ target: undefined,
},
];
const adminLinks: NavLink[] = [
@@ -376,7 +378,7 @@
view: undefined,
},
];
- const topMenus = [
+ const topMenus: TopMenuEntryInfo[] = [
{
name: 'Plugins',
items: [
@@ -548,7 +550,7 @@
{
name: 'Facebook',
url: 'https://facebook.com',
- target: '',
+ target: undefined,
},
];
const topMenus = [
@@ -575,7 +577,7 @@
{
name: 'Facebook',
url: 'https://facebook.com',
- target: '',
+ target: undefined,
},
{
name: 'Manage',
@@ -594,7 +596,7 @@
view: undefined,
},
];
- const topMenus = [
+ const topMenus: TopMenuEntryInfo[] = [
{
name: 'Browse',
items: [
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 8085e72..4034b8d 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -22,6 +22,7 @@
RepoName,
RevisionPatchSetNum,
UrlEncodedCommentId,
+ UserId,
} from '../../../types/common';
import {AppElement, AppElementParams} from '../../gr-app-types';
import {LocationChangeEventDetail} from '../../../types/events';
@@ -1038,7 +1039,7 @@
const state: DashboardViewState = {
view: GerritView.DASHBOARD,
type: DashboardType.USER,
- user: ctx.params[0],
+ user: ctx.params[0] as UserId | 'self' | undefined,
};
// Note that router model view must be updated before view models.
this.setState(state);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index beeda32..00a627b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -31,13 +31,13 @@
stubRestApi,
} from '../../../test/test-utils';
import {
+ Base64ImageFile,
BasePatchSetNum,
BlameInfo,
CommentRange,
CommentThread,
DraftInfo,
EDIT,
- ImageInfo,
NumericChangeId,
PARENT,
PatchSetNum,
@@ -238,8 +238,8 @@
});
suite('image diffs', () => {
- let mockFile1: ImageInfo;
- let mockFile2: ImageInfo;
+ let mockFile1: Base64ImageFile;
+ let mockFile2: Base64ImageFile;
setup(() => {
mockFile1 = {
body:
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 bd20a96..cc67a90 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
@@ -896,7 +896,7 @@
<div class="jumpToFileContainer">
<gr-dropdown-list
id="dropdown"
- .value=${this.path}
+ .value=${this.path ?? ''}
.items=${formattedFiles}
show-copy-for-trigger-text
@value-change=${this.handleFileChange}
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 e2e8701..f9bcebd 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
@@ -9,7 +9,12 @@
import '../../shared/gr-dialog/gr-dialog';
import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {ChangeInfo, RevisionPatchSetNum} from '../../../types/common';
+import {
+ ChangeInfo,
+ PARENT,
+ PatchSetNum,
+ RevisionPatchSetNum,
+} from '../../../types/common';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {
AutocompleteQuery,
@@ -68,7 +73,7 @@
change?: ChangeInfo;
@property({type: String})
- patchNum?: RevisionPatchSetNum;
+ patchNum?: PatchSetNum;
@property({type: Array})
hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
@@ -133,7 +138,7 @@
margin: var(--spacing-m) 0;
padding: var(--spacing-s);
width: 100%;
- box-sizing: content-box;
+ box-sizing: border-box;
}
#dragDropArea {
border: 2px dashed var(--border-color);
@@ -200,8 +205,8 @@
<gr-dialog
id="openDialog"
class="invisible dialog"
- ?disabled=${!this.isValidPath(this.path) || this.fileUploaded}
- ?disableCancel=${this.fileUploaded}
+ ?disabled=${!this.isValidPath(this.path) || !!this.fileUploaded}
+ ?disableCancel=${!!this.fileUploaded}
confirm-label="Confirm"
confirm-on-enter=""
@confirm=${(e: Event) => this.handleOpenConfirm(e)}
@@ -342,7 +347,7 @@
@bind-value-changed=${(e: BindValueChangeEvent) =>
this.handleBindValueChangedPath(e)}
>
- <input ?disabled=${''} />
+ <input disabled />
</iron-input>
</div>
</gr-dialog>
@@ -489,10 +494,15 @@
this.closeDialog(this.openDialog);
return;
}
+ const patchNum = this.patchNum;
assertIsDefined(this.patchNum, 'patchset number');
+ if (patchNum === PARENT) {
+ fireAlert(this, "This doesn't work on Parent");
+ }
const url = this.getViewModel().editUrl({
editView: {path: this.path},
- patchNum: this.patchNum,
+ // since parent is checked above, it's revision patchset.
+ patchNum: patchNum as RevisionPatchSetNum,
});
this.getNavigation().setUrl(url);
this.closeDialog(this.getDialogFromEvent(e));
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index ceb2136..e8ea633 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -179,7 +179,7 @@
<div class="header" slot="header">Restore this file?</div>
<div class="main" slot="main">
<iron-input>
- <input />
+ <input disabled="" />
</iron-input>
</div>
</gr-dialog>
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
new file mode 100644
index 0000000..af5faca
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token.ts
@@ -0,0 +1,427 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {getAppContext} from '../../../services/app-context';
+import {grFormStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {AuthTokenInfo} from '../../../types/common';
+import {BindValueChangeEvent} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {fireAlert} from '../../../utils/event-util';
+import {parseDate} from '../../../utils/date-util';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-auth-token': GrAuthToken;
+ }
+}
+
+@customElement('gr-auth-token')
+export class GrAuthToken extends LitElement {
+ @query('#generatedAuthTokenModal')
+ generatedAuthTokenModal?: HTMLDialogElement;
+
+ @query('#deleteAuthTokenModal')
+ deleteAuthTokenModal?: HTMLDialogElement;
+
+ @state()
+ loading = false;
+
+ @state()
+ username?: string;
+
+ @state()
+ generatedAuthToken?: AuthTokenInfo;
+
+ @state()
+ status?: string;
+
+ @state()
+ passwordUrl: string | null = null;
+
+ @state()
+ maxLifetime = 'unlimited';
+
+ @property({type: Array})
+ tokens: AuthTokenInfo[] = [];
+
+ @property({type: String})
+ newTokenId = '';
+
+ @property({type: String})
+ newLifetime = '';
+
+ @query('#generateButton') generateButton!: GrButton;
+
+ @query('#newToken') tokenInput!: IronInputElement;
+
+ @query('#lifetime') tokenLifetime!: IronInputElement;
+
+ private readonly restApiService = getAppContext().restApiService;
+
+ // Private but used in test
+ readonly getConfigModel = resolve(this, configModelToken);
+
+ // Private but used in test
+ readonly getUserModel = resolve(this, userModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ info => {
+ if (info) {
+ this.passwordUrl = info.auth.http_password_url || null;
+ this.maxLifetime = info.auth.max_token_lifetime || 'unlimited';
+ } else {
+ this.passwordUrl = null;
+ this.maxLifetime = 'unlimited';
+ }
+ }
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ account => {
+ if (account) {
+ this.username = account.username;
+ }
+ }
+ );
+ }
+
+ static override get styles() {
+ return [
+ sharedStyles,
+ grFormStyles,
+ modalStyles,
+ css`
+ .token {
+ font-family: var(--monospace-font-family);
+ font-size: var(--font-size-mono);
+ line-height: var(--line-height-mono);
+ }
+ #deleteAuthTokenModal {
+ padding: var(--spacing-xxl);
+ width: 50em;
+ }
+ #generatedAuthTokenModal {
+ padding: var(--spacing-xxl);
+ width: 50em;
+ }
+ #generatedAuthTokenDisplay {
+ margin: var(--spacing-l) 0;
+ }
+ #generatedAuthTokenDisplay .title {
+ width: unset;
+ }
+ #generatedAuthTokenDisplay .value {
+ font-family: var(--monospace-font-family);
+ font-size: var(--font-size-mono);
+ line-height: var(--line-height-mono);
+ }
+ #authTokenWarning {
+ font-style: italic;
+ text-align: center;
+ }
+ #existing {
+ margin-top: var(--spacing-l);
+ margin-bottom: var(--spacing-l);
+ }
+ #existing .idColumn {
+ min-width: 15em;
+ width: auto;
+ }
+ .closeButton {
+ bottom: 2em;
+ position: absolute;
+ right: 2em;
+ }
+ .expired {
+ color: var(--negative-red-text-color);
+ }
+ .lifeTimeInput {
+ min-width: 23em;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ return html` <div class="gr-form-styles">
+ <div ?hidden=${!!this.passwordUrl}>
+ <section>
+ <span class="title">Username</span>
+ <span class="value">${this.username ?? ''}</span>
+ </section>
+
+ <fieldset id="existing">
+ <table>
+ <thead>
+ <tr>
+ <th class="idColumn">ID</th>
+ <th class="expirationColumn">Expiration Date</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${this.tokens.map(tokenInfo => this.renderToken(tokenInfo))}
+ </tbody>
+ <tfoot>
+ ${this.renderFooterRow()}
+ </tfoot>
+ </table>
+ </fieldset>
+ </div>
+ <span ?hidden=${!this.passwordUrl}>
+ <a
+ href=${this.passwordUrl!}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Obtain password</a
+ >
+ (opens in a new tab)
+ </span>
+ </div>
+ <dialog
+ tabindex="-1"
+ id="generatedAuthTokenModal"
+ @closed=${this.generatedAuthTokenModalClosed}
+ >
+ <div class="gr-form-styles">
+ <section id="generatedAuthTokenDisplay">
+ <span class="title">New Token:</span>
+ <span class="value"
+ >${this.status || this.generatedAuthToken?.token}</span
+ >
+ <gr-copy-clipboard
+ hasTooltip=""
+ buttonTitle="Copy token to clipboard"
+ hideInput=""
+ .text=${this.status ? '' : this.generatedAuthToken?.token}
+ >
+ </gr-copy-clipboard>
+ </section>
+ <section
+ id="authTokenWarning"
+ ?hidden=${!this.generatedAuthToken?.expiration}
+ >
+ This token will be valid until
+ <gr-date-formatter
+ showDateAndTime
+ withTooltip
+ .dateStr=${this.generatedAuthToken?.expiration}
+ ></gr-date-formatter>
+ .
+ </section>
+ <section id="authTokenWarning">
+ This token will not be displayed again.<br />
+ If you lose it, you will need to generate a new one.
+ </section>
+ <gr-button
+ link=""
+ class="closeButton"
+ @click=${this.closeGenerateModal}
+ >Close</gr-button
+ >
+ </div>
+ </dialog>
+ <dialog tabindex="-1" id="deleteAuthTokenModal">
+ <gr-dialog
+ id="deleteDialog"
+ class="confirmDialog"
+ confirm-label="Delete"
+ confirm-on-enter
+ @confirm=${() => this.handleDeleteAuthTokenConfirmed()}
+ @cancel=${() => this.closeDeleteModal()}
+ >
+ <div class="header" slot="header">Delete Authentication Token</div>
+ <div class="main" slot="main">
+ <section>
+ Do you really want to delete the token? The deletion cannot be
+ reverted.
+ </section>
+ </div>
+ </gr-dialog>
+ </dialog>`;
+ }
+
+ private renderToken(tokenInfo: AuthTokenInfo) {
+ return html` <tr class=${this.isTokenExpired(tokenInfo) ? 'expired' : ''}>
+ <td class="idColumn">${tokenInfo.id}</td>
+ <td class="expirationColumn">
+ <gr-date-formatter
+ withTooltip
+ showDateAndTime
+ dateFormat="STD"
+ .dateStr=${tokenInfo.expiration}
+ ></gr-date-formatter>
+ </td>
+ <td>
+ <gr-button
+ id="deleteButton"
+ aria-label=${`delete token ${tokenInfo.id}`}
+ @click=${() => this.handleDeleteTap(tokenInfo.id)}
+ >Delete</gr-button
+ >
+ </td>
+ </tr>`;
+ }
+
+ private renderFooterRow() {
+ return html`
+ <tr>
+ <th style="vertical-align: top;">
+ <iron-input
+ id="newToken"
+ .bindValue=${this.newTokenId}
+ @bind-value-changed=${(e: BindValueChangeEvent) => {
+ this.newTokenId = e.detail.value ?? '';
+ }}
+ >
+ <input
+ is="iron-input"
+ placeholder="New Token ID"
+ @keydown=${this.handleInputKeydown}
+ />
+ </iron-input>
+ </th>
+ <th style="vertical-align: top;">
+ <iron-input
+ .bindValue=${this.newLifetime}
+ @bind-value-changed=${(e: BindValueChangeEvent) => {
+ this.newLifetime = e.detail.value ?? '';
+ }}
+ >
+ <input
+ class="lifeTimeInput"
+ is="iron-input"
+ placeholder="Lifetime (e.g. 30d)"
+ @keydown=${this.handleInputKeydown}
+ />
+ </iron-input></br>
+ (Max. allowed lifetime: ${this.formatDuration(this.maxLifetime)})
+ </th>
+ <th>
+ <gr-button
+ id="generateButton"
+ link=""
+ ?loading=${this.loading}
+ ?disabled=${!this.newTokenId.length}
+ @click=${this.handleGenerateTap}
+ >Generate</gr-button
+ >
+ </th>
+ </tr>
+ `;
+ }
+
+ private formatDuration(durationMinutes: string) {
+ if (!durationMinutes) return '';
+ if (durationMinutes === 'unlimited') return 'unlimited';
+ let minutes = parseInt(durationMinutes, 10);
+ let hours = Math.floor(minutes / 60);
+ minutes = minutes % 60;
+ let days = Math.floor(hours / 24);
+ hours = hours % 24;
+ const years = Math.floor(days / 365);
+ days = days % 365;
+ let formatted = '';
+ if (years) formatted += `${years}y `;
+ if (days) formatted += `${days}d `;
+ if (hours) formatted += `${hours}h `;
+ if (minutes) formatted += `${minutes}m`;
+ return formatted;
+ }
+
+ loadData() {
+ return this.restApiService.getAccountAuthTokens().then(tokens => {
+ if (!tokens) return;
+ this.tokens = tokens;
+ });
+ }
+
+ private isTokenExpired(tokenInfo: AuthTokenInfo) {
+ if (!tokenInfo.expiration) return false;
+ return parseDate(tokenInfo.expiration) < new Date();
+ }
+
+ private handleGenerateTap() {
+ this.loading = true;
+ this.status = 'Generating...';
+ this.generatedAuthTokenModal?.showModal();
+ this.restApiService
+ .generateAccountAuthToken(this.newTokenId, this.newLifetime)
+ .then(newToken => {
+ if (newToken) {
+ this.generatedAuthToken = newToken;
+ this.status = undefined;
+ this.loadData();
+ this.tokenInput.bindValue = '';
+ this.tokenLifetime.bindValue = '';
+ } else {
+ this.status = 'Failed to generate';
+ }
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ }
+
+ private handleDeleteTap(id: string) {
+ this.deleteAuthTokenModal?.setAttribute('tokenId', id);
+ this.deleteAuthTokenModal?.showModal();
+ }
+
+ private handleDeleteAuthTokenConfirmed() {
+ const id = this.deleteAuthTokenModal?.getAttribute('tokenId');
+ if (id === undefined || id === null) {
+ return;
+ }
+ this.restApiService
+ .deleteAccountAuthToken(id)
+ .then(() => {
+ this.loadData();
+ })
+ .catch(err => {
+ fireAlert(this, `Failed to delete token: ${err}`);
+ })
+ .finally(() => {
+ this.closeDeleteModal();
+ });
+ }
+
+ private closeGenerateModal() {
+ this.generatedAuthTokenModal?.close();
+ }
+
+ private closeDeleteModal() {
+ this.deleteAuthTokenModal?.close();
+ }
+
+ private generatedAuthTokenModalClosed() {
+ this.status = undefined;
+ this.generatedAuthToken = undefined;
+ }
+
+ private handleInputKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ this.handleGenerateTap();
+ }
+ }
+}
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
new file mode 100644
index 0000000..d6028d8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token_test.ts
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-auth-token';
+import {GrAuthToken} from './gr-auth-token';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {
+ createAccountDetailWithId,
+ createServerInfo,
+} from '../../../test/test-data-generators';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {assert, fixture, html, waitUntil} from '@open-wc/testing';
+import {AuthTokenInfo} from '../../../types/common';
+
+suite('gr-auth-token tests', () => {
+ let element: GrAuthToken;
+ let account: AccountDetailInfo;
+ let config: ServerInfo;
+
+ setup(async () => {
+ account = {...createAccountDetailWithId(), username: 'user name'};
+ config = createServerInfo();
+
+ stubRestApi('getAccount').returns(Promise.resolve(account));
+ stubRestApi('getConfig').returns(Promise.resolve(config));
+
+ element = await fixture(html`<gr-auth-token></gr-auth-token>`);
+ await waitUntil(
+ () => element.getUserModel().getState().account === account
+ );
+ await waitUntil(
+ () => element.getConfigModel().getState().serverConfig === config
+ );
+ await waitEventLoop();
+ });
+
+ test('renders', () => {
+ assert.shadowDom.equal(
+ element,
+ `
+ <div class="gr-form-styles">
+ <div>
+ <section>
+ <span class="title"> Username </span>
+ <span class="value"> user name </span>
+ </section>
+ <fieldset id="existing">
+ <table>
+ <thead>
+ <tr>
+ <th class="idColumn">ID</th>
+ <th class="expirationColumn">Expiration Date</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody></tbody>
+ <tfoot>
+ <tr>
+ <th style="vertical-align: top;">
+ <iron-input id="newToken">
+ <input is="iron-input" placeholder="New Token ID" />
+ </iron-input>
+ </th>
+ <th style="vertical-align: top;">
+ <iron-input>
+ <input
+ class="lifeTimeInput"
+ is="iron-input"
+ placeholder="Lifetime (e.g. 30d)"
+ />
+ </iron-input>
+ </br>
+ (Max. allowed lifetime: unlimited)
+ </th>
+ <th>
+ <gr-button
+ aria-disabled="true"
+ id="generateButton"
+ link=""
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >Generate</gr-button
+ >
+ </th>
+ </tr>
+ </tfoot>
+ </table>
+ </fieldset>
+ </div>
+ <span hidden="">
+ <a href="" target="_blank" rel="noopener noreferrer">
+ Obtain password
+ </a>
+ (opens in a new tab)
+ </span>
+ </div>
+ <dialog tabindex="-1" id="generatedAuthTokenModal">
+ <div class="gr-form-styles">
+ <section id="generatedAuthTokenDisplay">
+ <span class="title"> New Token: </span>
+ <span class="value"> </span>
+ <gr-copy-clipboard
+ buttontitle="Copy token to clipboard"
+ hastooltip=""
+ hideinput=""
+ >
+ </gr-copy-clipboard>
+ </section>
+ <section hidden="" id="authTokenWarning">
+ This token will be valid until
+ <gr-date-formatter showdateandtime="" withtooltip="">
+ </gr-date-formatter>
+ .
+ </section>
+ <section id="authTokenWarning">
+ This token will not be displayed again.
+ <br />
+ If you lose it, you will need to generate a new one.
+ </section>
+ <gr-button
+ aria-disabled="false"
+ class="closeButton"
+ link=""
+ role="button"
+ tabindex="0"
+ >
+ Close
+ </gr-button>
+ </div>
+ </dialog>
+ <dialog id="deleteAuthTokenModal" tabindex="-1">
+ <gr-dialog
+ class="confirmDialog"
+ confirm-label="Delete"
+ confirm-on-enter=""
+ id="deleteDialog"
+ >
+ <div class="header" slot="header">Delete Authentication Token</div>
+ <div class="main" slot="main">
+ <section>
+ Do you really want to delete the token? The deletion cannot be
+ reverted.
+ </section>
+ </div>
+ </gr-dialog>
+ </dialog>
+ `
+ );
+ });
+
+ test('generate token', async () => {
+ const button = queryAndAssert<GrButton>(element, '#generateButton');
+ const nextToken = {id: 'next-token-id', token: 'next-token'};
+ let generateResolve: (
+ value: AuthTokenInfo | PromiseLike<AuthTokenInfo>
+ ) => void;
+ const generateStub = stubRestApi('generateAccountAuthToken').callsFake(
+ () =>
+ new Promise(resolve => {
+ generateResolve = resolve;
+ })
+ );
+
+ assert.isNotOk(element.generatedAuthToken);
+
+ element.tokenInput.bindValue = nextToken.id;
+
+ await element.updateComplete;
+
+ assert.isFalse(button.disabled);
+ button.click();
+
+ assert.isTrue(generateStub.called);
+ assert.equal(element.status, 'Generating...');
+
+ generateStub.lastCall.returnValue.then(() => {
+ generateResolve(nextToken);
+ assert.equal(element.generatedAuthToken, nextToken);
+ });
+ });
+
+ test('without http_password_url', () => {
+ assert.isNull(element.passwordUrl);
+ });
+
+ test('with http_password_url', async () => {
+ config.auth.http_password_url = 'http://example.com/';
+ element.passwordUrl = config.auth.http_password_url;
+ await element.updateComplete;
+ assert.isNotNull(element.passwordUrl);
+ assert.equal(element.passwordUrl, config.auth.http_password_url);
+ });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index b9a102a..b1bae82 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -89,7 +89,7 @@
type="checkbox"
name="number"
@click=${this.handleNumberCheckboxClick}
- ?checked=${this.showNumber}
+ ?checked=${!!this.showNumber}
/>
</td>
</tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 54eac30..06f11e8 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -142,7 +142,7 @@
<div id="agreementsUrl" class="agreementsUrl">
<a
href=${ifDefined(this.agreementsUrl)}
- target="blank"
+ target="_blank"
rel="noopener"
>
Please review the agreement.</a
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 fb2e431..87931e1 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
@@ -112,7 +112,7 @@
<input
id="editSyntaxHighlighting"
type="checkbox"
- ?checked=${this.editPrefs?.syntax_highlighting}
+ ?checked=${!!this.editPrefs?.syntax_highlighting}
@change=${this.handleEditSyntaxHighlightingChanged}
/>
</span>
@@ -123,7 +123,7 @@
<input
id="editShowTabs"
type="checkbox"
- ?checked=${this.editPrefs?.show_tabs}
+ ?checked=${!!this.editPrefs?.show_tabs}
@change=${this.handleEditShowTabsChanged}
/>
</span>
@@ -136,7 +136,7 @@
<input
id="editShowTrailingWhitespaceInput"
type="checkbox"
- ?checked=${this.editPrefs?.show_whitespace_errors}
+ ?checked=${!!this.editPrefs?.show_whitespace_errors}
@change=${this.handleEditShowTrailingWhitespaceTap}
/>
</span>
@@ -147,7 +147,7 @@
<input
id="showMatchBrackets"
type="checkbox"
- ?checked=${this.editPrefs?.match_brackets}
+ ?checked=${!!this.editPrefs?.match_brackets}
@change=${this.handleMatchBracketsChanged}
/>
</span>
@@ -158,7 +158,7 @@
<input
id="editShowLineWrapping"
type="checkbox"
- ?checked=${this.editPrefs?.line_wrapping}
+ ?checked=${!!this.editPrefs?.line_wrapping}
@change=${this.handleEditLineWrappingChanged}
/>
</span>
@@ -169,7 +169,7 @@
<input
id="showIndentWithTabs"
type="checkbox"
- ?checked=${this.editPrefs?.indent_with_tabs}
+ ?checked=${!!this.editPrefs?.indent_with_tabs}
@change=${this.handleIndentWithTabsChanged}
/>
</span>
@@ -182,7 +182,7 @@
<input
id="showAutoCloseBrackets"
type="checkbox"
- ?checked=${this.editPrefs?.auto_close_brackets}
+ ?checked=${!!this.editPrefs?.auto_close_brackets}
@change=${this.handleAutoCloseBracketsChanged}
/>
</span>
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 eb04e6d..d8fa464 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
@@ -107,7 +107,7 @@
type="radio"
name="preferred"
.value=${email.email}
- .checked=${email.preferred}
+ .checked=${!!email.preferred}
@change=${this.handlePreferredChange}
/>
</td>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
deleted file mode 100644
index f2e6681..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {getAppContext} from '../../../services/app-context';
-import {grFormStyles} from '../../../styles/gr-form-styles';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html, LitElement} from 'lit';
-import {customElement, query, state} from 'lit/decorators.js';
-import {modalStyles} from '../../../styles/gr-modal-styles';
-import {resolve} from '../../../models/dependency';
-import {configModelToken} from '../../../models/config/config-model';
-import {userModelToken} from '../../../models/user/user-model';
-import {subscribe} from '../../lit/subscription-controller';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-http-password': GrHttpPassword;
- }
-}
-
-@customElement('gr-http-password')
-export class GrHttpPassword extends LitElement {
- @query('#generatedPasswordModal')
- generatedPasswordModal?: HTMLDialogElement;
-
- @state()
- username?: string;
-
- @state()
- generatedPassword?: string;
-
- @state()
- status?: string;
-
- @state()
- passwordUrl: string | null = null;
-
- private readonly restApiService = getAppContext().restApiService;
-
- // Private but used in test
- readonly getConfigModel = resolve(this, configModelToken);
-
- // Private but used in test
- readonly getUserModel = resolve(this, userModelToken);
-
- constructor() {
- super();
- subscribe(
- this,
- () => this.getConfigModel().serverConfig$,
- info => {
- if (info) {
- this.passwordUrl = info.auth.http_password_url || null;
- } else {
- this.passwordUrl = null;
- }
- }
- );
- subscribe(
- this,
- () => this.getUserModel().account$,
- account => {
- if (account) {
- this.username = account.username;
- }
- }
- );
- }
-
- static override get styles() {
- return [
- sharedStyles,
- grFormStyles,
- modalStyles,
- css`
- .password {
- font-family: var(--monospace-font-family);
- font-size: var(--font-size-mono);
- line-height: var(--line-height-mono);
- }
- #generatedPasswordModal {
- padding: var(--spacing-xxl);
- width: 50em;
- }
- #generatedPasswordDisplay {
- margin: var(--spacing-l) 0;
- }
- #generatedPasswordDisplay .title {
- width: unset;
- }
- #generatedPasswordDisplay .value {
- font-family: var(--monospace-font-family);
- font-size: var(--font-size-mono);
- line-height: var(--line-height-mono);
- }
- #passwordWarning {
- font-style: italic;
- text-align: center;
- }
- .closeButton {
- bottom: 2em;
- position: absolute;
- right: 2em;
- }
- `,
- ];
- }
-
- override render() {
- return html` <div class="gr-form-styles">
- <div ?hidden=${!!this.passwordUrl}>
- <section>
- <span class="title">Username</span>
- <span class="value">${this.username ?? ''}</span>
- </section>
- <gr-button id="generateButton" @click=${this._handleGenerateTap}
- >Generate New Password</gr-button
- >
- </div>
- <span ?hidden=${!this.passwordUrl}>
- <a
- href=${this.passwordUrl!}
- target="_blank"
- rel="noopener noreferrer"
- >
- Obtain password</a
- >
- (opens in a new tab)
- </span>
- </div>
- <dialog
- tabindex="-1"
- id="generatedPasswordModal"
- @closed=${this._generatedPasswordModalClosed}
- >
- <div class="gr-form-styles">
- <section id="generatedPasswordDisplay">
- <span class="title">New Password:</span>
- <span class="value">${this.status || this.generatedPassword}</span>
- <gr-copy-clipboard
- hasTooltip=""
- buttonTitle="Copy password to clipboard"
- hideInput=""
- .text=${this.status ? '' : this.generatedPassword}
- >
- </gr-copy-clipboard>
- </section>
- <section id="passwordWarning">
- This password will not be displayed again.<br />
- If you lose it, you will need to generate a new one.
- </section>
- <gr-button link="" class="closeButton" @click=${this._closeModal}
- >Close</gr-button
- >
- </div>
- </dialog>`;
- }
-
- _handleGenerateTap() {
- this.status = 'Generating...';
- this.generatedPasswordModal?.showModal();
- this.restApiService.generateAccountHttpPassword().then(newPassword => {
- if (newPassword) {
- this.generatedPassword = newPassword;
- this.status = undefined;
- } else {
- this.status = 'Failed to generate';
- }
- });
- }
-
- _closeModal() {
- this.generatedPasswordModal?.close();
- }
-
- _generatedPasswordModalClosed() {
- this.status = undefined;
- this.generatedPassword = '';
- }
-}
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
deleted file mode 100644
index 4271995..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-http-password';
-import {GrHttpPassword} from './gr-http-password';
-import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
-import {
- createAccountDetailWithId,
- createServerInfo,
-} from '../../../test/test-data-generators';
-import {AccountDetailInfo, ServerInfo} from '../../../types/common';
-import {queryAndAssert} from '../../../test/test-utils';
-import {GrButton} from '../../shared/gr-button/gr-button';
-import {assert, fixture, html, waitUntil} from '@open-wc/testing';
-
-suite('gr-http-password tests', () => {
- let element: GrHttpPassword;
- let account: AccountDetailInfo;
- let config: ServerInfo;
-
- setup(async () => {
- account = {...createAccountDetailWithId(), username: 'user name'};
- config = createServerInfo();
-
- stubRestApi('getAccount').returns(Promise.resolve(account));
- stubRestApi('getConfig').returns(Promise.resolve(config));
-
- element = await fixture(html`<gr-http-password></gr-http-password>`);
- await waitUntil(
- () => element.getUserModel().getState().account === account
- );
- await waitUntil(
- () => element.getConfigModel().getState().serverConfig === config
- );
- await waitEventLoop();
- });
-
- test('renders', () => {
- assert.shadowDom.equal(
- element,
- /* HTML */ `
- <div class="gr-form-styles">
- <div>
- <section>
- <span class="title"> Username </span>
- <span class="value"> user name </span>
- </section>
- <gr-button
- aria-disabled="false"
- id="generateButton"
- role="button"
- tabindex="0"
- >
- Generate New Password
- </gr-button>
- </div>
- <span hidden="">
- <a href="" target="_blank" rel="noopener noreferrer">
- Obtain password
- </a>
- (opens in a new tab)
- </span>
- </div>
- <dialog tabindex="-1" id="generatedPasswordModal">
- <div class="gr-form-styles">
- <section id="generatedPasswordDisplay">
- <span class="title"> New Password: </span>
- <span class="value"> </span>
- <gr-copy-clipboard
- buttontitle="Copy password to clipboard"
- hastooltip=""
- hideinput=""
- >
- </gr-copy-clipboard>
- </section>
- <section id="passwordWarning">
- This password will not be displayed again.
- <br />
- If you lose it, you will need to generate a new one.
- </section>
- <gr-button
- aria-disabled="false"
- class="closeButton"
- link=""
- role="button"
- tabindex="0"
- >
- Close
- </gr-button>
- </div>
- </dialog>
- `
- );
- });
-
- test('generate password', () => {
- const button = queryAndAssert<GrButton>(element, '#generateButton');
- const nextPassword = 'the new password';
- let generateResolve: (value: string | PromiseLike<string>) => void;
- const generateStub = stubRestApi('generateAccountHttpPassword').callsFake(
- () =>
- new Promise(resolve => {
- generateResolve = resolve;
- })
- );
-
- assert.isNotOk(element.generatedPassword);
-
- button.click();
-
- assert.isTrue(generateStub.called);
- assert.equal(element.status, 'Generating...');
-
- generateStub.lastCall.returnValue.then(() => {
- generateResolve(nextPassword);
- assert.equal(element.generatedPassword, nextPassword);
- });
- });
-
- test('without http_password_url', () => {
- assert.isNull(element.passwordUrl);
- });
-
- test('with http_password_url', async () => {
- config.auth.http_password_url = 'http://example.com/';
- element.passwordUrl = config.auth.http_password_url;
- await element.updateComplete;
- assert.isNotNull(element.passwordUrl);
- assert.equal(element.passwordUrl, config.auth.http_password_url);
- });
-});
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 6a85660..e7e86c6 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -250,7 +250,7 @@
this.menuItems.push({
name: this.newName,
url: this.newUrl,
- target: this.newTarget ? '_blank' : '',
+ target: this.newTarget ? '_blank' : undefined,
});
this.newName = '';
this.newUrl = '';
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 4c6f151..313ac35 100644
--- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -297,7 +297,7 @@
<input
id="relativeDateInChangeTable"
type="checkbox"
- ?checked=${this.prefs?.relative_date_in_change_table}
+ ?checked=${!!this.prefs?.relative_date_in_change_table}
@change=${() => {
this.prefs!.relative_date_in_change_table =
this.relativeDateInChangeTable.checked;
@@ -332,7 +332,7 @@
<input
id="showSizeBarsInFileList"
type="checkbox"
- ?checked=${this.prefs?.size_bar_in_change_table}
+ ?checked=${!!this.prefs?.size_bar_in_change_table}
@change=${() => {
this.prefs!.size_bar_in_change_table =
this.showSizeBarsInFileList.checked;
@@ -349,7 +349,7 @@
<input
id="publishCommentsOnPush"
type="checkbox"
- ?checked=${this.prefs?.publish_comments_on_push}
+ ?checked=${!!this.prefs?.publish_comments_on_push}
@change=${() => {
this.prefs!.publish_comments_on_push =
this.publishCommentsOnPush.checked;
@@ -366,7 +366,7 @@
<input
id="workInProgressByDefault"
type="checkbox"
- ?checked=${this.prefs?.work_in_progress_by_default}
+ ?checked=${!!this.prefs?.work_in_progress_by_default}
@change=${() => {
this.prefs!.work_in_progress_by_default =
this.workInProgressByDefault.checked;
@@ -383,7 +383,7 @@
<input
id="disableKeyboardShortcuts"
type="checkbox"
- ?checked=${this.prefs?.disable_keyboard_shortcuts}
+ ?checked=${!!this.prefs?.disable_keyboard_shortcuts}
@change=${() => {
this.prefs!.disable_keyboard_shortcuts =
this.disableKeyboardShortcuts.checked;
@@ -400,7 +400,7 @@
<input
id="disableTokenHighlighting"
type="checkbox"
- ?checked=${this.prefs?.disable_token_highlighting}
+ ?checked=${!!this.prefs?.disable_token_highlighting}
@change=${() => {
this.prefs!.disable_token_highlighting =
this.disableTokenHighlighting.checked;
@@ -417,7 +417,7 @@
<input
id="insertSignedOff"
type="checkbox"
- ?checked=${this.prefs?.signed_off_by}
+ ?checked=${!!this.prefs?.signed_off_by}
@change=${() => {
this.prefs!.signed_off_by = this.insertSignedOff.checked;
this.requestUpdate();
@@ -468,7 +468,7 @@
<input
id="allowBrowserNotifications"
type="checkbox"
- ?checked=${this.prefs?.allow_browser_notifications}
+ ?checked=${!!this.prefs?.allow_browser_notifications}
@change=${() => {
this.prefs!.allow_browser_notifications =
this.allowBrowserNotifications!.checked;
@@ -484,6 +484,9 @@
private renderGenerateSuggestionWhenCommenting() {
if (
!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2) ||
+ this.flagsService.isEnabled(
+ KnownExperimentId.ML_SUGGESTED_EDIT_UNCHECK_BY_DEFAULT
+ ) ||
!this.suggestionsProvider
)
return nothing;
@@ -509,7 +512,7 @@
<input
id="allowSuggestCodeWhileCommenting"
type="checkbox"
- ?checked=${this.prefs?.allow_suggest_code_while_commenting}
+ ?checked=${!!this.prefs?.allow_suggest_code_while_commenting}
@change=${() => {
this.prefs!.allow_suggest_code_while_commenting =
this.allowSuggestCodeWhileCommenting!.checked;
@@ -540,7 +543,7 @@
<input
id="allowAiCommentAutocompletion"
type="checkbox"
- ?checked=${this.prefs?.allow_autocompleting_comments}
+ ?checked=${!!this.prefs?.allow_autocompleting_comments}
@change=${() => {
this.prefs!.allow_autocompleting_comments =
this.allowAiCommentAutocompletion!.checked;
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 3f97b01..b1c8ebc 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
@@ -17,7 +17,7 @@
import '../gr-email-editor/gr-email-editor';
import '../gr-gpg-editor/gr-gpg-editor';
import '../gr-group-list/gr-group-list';
-import '../gr-http-password/gr-http-password';
+import '../gr-auth-token/gr-auth-token';
import '../gr-identities/gr-identities';
import '../gr-menu-editor/gr-menu-editor';
import '../gr-preferences/gr-preferences';
@@ -60,6 +60,7 @@
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {rootUrl} from '../../../utils/url-util';
import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
+import {GrAuthToken} from '../gr-auth-token/gr-auth-token';
const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
@@ -97,6 +98,8 @@
@queryAsync('#sshEditor') sshEditorPromise!: Promise<GrSshEditor>;
+ @queryAsync('#authToken') tokenEditorPromise!: Promise<GrAuthToken>;
+
@queryAsync('#gpgEditor') gpgEditorPromise!: Promise<GrGpgEditor>;
@query('#emailEditor', true) emailEditor!: GrEmailEditor;
@@ -244,6 +247,12 @@
);
}
+ if (this.showHttpAuth()) {
+ configPromises.push(
+ this.tokenEditorPromise.then(tokenEditor => tokenEditor.loadData())
+ );
+ }
+
return Promise.all(configPromises);
})
);
@@ -600,7 +609,7 @@
() => html` <div>
<h2 id="HTTPCredentials">HTTP Credentials</h2>
<fieldset>
- <gr-http-password id="httpPass"></gr-http-password>
+ <gr-auth-token id="authToken"></gr-auth-token>
</fieldset>
</div>`
)}
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 9027e35..7059228 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -88,7 +88,7 @@
<gr-button
link=""
class="action"
- ?hidden=${this._hideActionButton}
+ ?hidden=${!!this._hideActionButton}
@click=${this._handleActionTap}
>${actionText}
</gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
new file mode 100644
index 0000000..5bd04d8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2022 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 {visualDiffDarkTheme} from '../../../test/test-utils';
+import {GrCommentThread} from './gr-comment-thread';
+import './gr-comment-thread';
+import {
+ CommentInfo,
+ DraftInfo,
+ NumericChangeId,
+ RepoName,
+ SavingState,
+ Timestamp,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+ createAccountDetailWithId,
+ createThread,
+} from '../../../test/test-data-generators';
+import {
+ ChangeChildView,
+ changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
+import {testResolver} from '../../../test/common-test-setup';
+
+const c1: CommentInfo = {
+ author: createAccountDetailWithId(1),
+ id: 'the-root' as UrlEncodedCommentId,
+ message: 'start the conversation',
+ updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c2: CommentInfo = {
+ author: createAccountDetailWithId(2),
+ id: 'the-reply' as UrlEncodedCommentId,
+ message: 'keep it going',
+ updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+ in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3: DraftInfo = {
+ author: createAccountDetailWithId(1),
+ id: 'the-draft' as UrlEncodedCommentId,
+ message: 'stop it',
+ updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+ in_reply_to: 'the-reply' as UrlEncodedCommentId,
+ savingState: SavingState.OK,
+};
+
+suite('gr-comment-thread screenshot tests', () => {
+ let element: GrCommentThread;
+
+ setup(async () => {
+ testResolver(changeViewModelToken).setState({
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
+ changeNum: 1 as NumericChangeId,
+ repo: 'test-repo-name' as RepoName,
+ });
+ element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
+ element.changeNum = 1 as NumericChangeId;
+ element.showFileName = true;
+ element.showFilePath = true;
+ element.repoName = 'test-repo-name' as RepoName;
+ await element.updateComplete;
+ });
+
+ test('unresolved', async () => {
+ element.thread = createThread(c1, {...c2, unresolved: true});
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-comment-thread-unresolved');
+ await visualDiffDarkTheme(element, 'gr-comment-thread-unresolved');
+ });
+
+ test('resolved', async () => {
+ element.thread = createThread(c1, c2);
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-comment-thread-resolved');
+ await visualDiffDarkTheme(element, 'gr-comment-thread-resolved');
+ });
+
+ test('with draft', async () => {
+ element.thread = createThread(c1, c2, c3);
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-comment-thread-with-draft');
+ await visualDiffDarkTheme(element, 'gr-comment-thread-with-draft');
+ });
+});
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 d19d63b..801fd023 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -469,6 +469,14 @@
) {
this.generateSuggestion = !!prefs.allow_suggest_code_while_commenting;
}
+ if (
+ this.flagsService.isEnabled(
+ KnownExperimentId.ML_SUGGESTED_EDIT_UNCHECK_BY_DEFAULT
+ ) &&
+ this.generateSuggestion
+ ) {
+ this.generateSuggestion = false;
+ }
}
);
subscribe(
@@ -866,7 +874,7 @@
<input
type="checkbox"
class="show-hide"
- ?checked=${this.collapsed}
+ ?checked=${!!this.collapsed}
@change=${() => (this.collapsed = !this.collapsed)}
/>
<gr-icon icon=${icon} id="icon"></gr-icon>
@@ -1165,7 +1173,7 @@
'Select to show a generated suggestion based on your comment for commented text. This suggestion can be inserted as a code block in your comment.';
return html`
<div class="action">
- <label title=${tooltip}>
+ <label title=${tooltip} class="suggestEdit">
<input
type="checkbox"
id="generateSuggestCheckbox"
@@ -1217,9 +1225,6 @@
}
private getNumberOfSuggestions() {
- if (!this.generateSuggestion) {
- return '';
- }
if (this.generatedFixSuggestion) {
return '(1)';
} else {
@@ -1227,11 +1232,11 @@
}
}
- private async generateSuggestEdit() {
+ // private used in test
+ async generateSuggestEdit() {
if (
!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2) ||
!this.showGeneratedSuggestion() ||
- !this.generateSuggestion ||
this.messageText.length === 0
)
return;
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 4173e28..1f57d38 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
@@ -927,6 +927,39 @@
.returns(true);
});
+ test('shows suggestion count when unchecked', async () => {
+ const comment: DraftInfo = {
+ ...createDraft(),
+ author: {
+ name: 'Mr. Peanutbutter',
+ email: 'tenn1sballchaser@aol.com' as EmailAddress,
+ },
+ line: 5,
+ path: 'test',
+ savingState: SavingState.OK,
+ message: 'hello world',
+ };
+ element = await fixture(
+ html`<gr-comment
+ .account=${account}
+ .showPatchset=${true}
+ .comment=${comment}
+ .initiallyCollapsed=${false}
+ ></gr-comment>`
+ );
+ element.editing = true;
+ sinon.stub(element, 'showGeneratedSuggestion').returns(true);
+ element.generateSuggestion = false;
+ element.generatedFixSuggestion = generatedFixSuggestion;
+ await element.updateComplete;
+
+ const label = queryAndAssert<HTMLLabelElement>(
+ element,
+ 'label.suggestEdit'
+ );
+ assert.include(label.textContent, '(1)');
+ });
+
test('renders suggestions in comment', async () => {
const comment = {
...createComment(),
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 0f9226c..b9bc001 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
@@ -100,7 +100,7 @@
<input
id="lineWrappingInput"
type="checkbox"
- ?checked=${this.diffPrefs?.line_wrapping}
+ ?checked=${!!this.diffPrefs?.line_wrapping}
@change=${this.handleLineWrappingTap}
/>
</span>
@@ -147,7 +147,7 @@
<input
id="showTabsInput"
type="checkbox"
- ?checked=${this.diffPrefs?.show_tabs}
+ ?checked=${!!this.diffPrefs?.show_tabs}
@change=${this.handleShowTabsTap}
/>
</span>
@@ -160,7 +160,7 @@
<input
id="showTrailingWhitespaceInput"
type="checkbox"
- ?checked=${this.diffPrefs?.show_whitespace_errors}
+ ?checked=${!!this.diffPrefs?.show_whitespace_errors}
@change=${this.handleShowTrailingWhitespaceTap}
/>
</span>
@@ -173,7 +173,7 @@
<input
id="syntaxHighlightInput"
type="checkbox"
- ?checked=${this.diffPrefs?.syntax_highlighting}
+ ?checked=${!!this.diffPrefs?.syntax_highlighting}
@change=${this.handleSyntaxHighlightTap}
/>
</span>
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 ef3f960..7c4db7f 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
@@ -156,7 +156,6 @@
}
.dropdown {
position: relative;
- z-index: 120;
}
.bottomContent {
color: var(--deemphasized-text-color);
@@ -189,6 +188,20 @@
display: inline-flex;
vertical-align: top;
}
+ .mobileText {
+ display: none;
+ }
+ .desktopText {
+ display: inline-block;
+ }
+ @media only screen and (max-width: 50em) {
+ .mobileText {
+ display: inline-block;
+ }
+ .desktopText {
+ display: none;
+ }
+ }
`,
];
}
@@ -251,7 +264,7 @@
return html`<div class="dropdown">
<gr-button
id="trigger"
- ?disabled=${this.disabled}
+ ?disabled=${!!this.disabled}
down-arrow
link
class="dropdown-trigger"
@@ -310,7 +323,7 @@
<md-menu-item
?selected=${this.value === String(item.value)}
?active=${this.value === String(item.value)}
- ?disabled=${item.disabled}
+ ?disabled=${!!item.disabled}
@click=${() => {
this.value = String(item.value);
}}
@@ -339,7 +352,8 @@
})}
>
<div>
- <span>${item.text}</span>
+ <span class="desktopText">${item.text}</span>
+ <span class="mobileText">${this.computeMobileText(item)}</span>
${when(
!!item.deemphasizeReason,
() => html`<span>| ${item.deemphasizeReason}</span>`
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 84610b0..eedee08 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,11 @@
<md-menu-item md-menu-item="" tabindex="0">
<div class="topContent">
<div>
- <span> Top Text 1 </span>
+ <span class="desktopText">
+ Top Text 1
+ </span>
+ <span class="mobileText">
+ Top Text 1
</div>
</div>
</md-menu-item>
@@ -89,7 +93,10 @@
<md-menu-item active="" md-menu-item="" selected="" tabindex="-1">
<div class="topContent">
<div>
- <span> Top Text 2 </span>
+ <span class="desktopText">
+ Top Text 2
+ </span>
+ <span class="mobileText"> Mobile Text 2 </span>
</div>
</div>
<div class="bottomContent">
@@ -100,7 +107,10 @@
<md-menu-item disabled="" md-menu-item="" tabindex="-1">
<div class="topContent">
<div>
- <span> Top Text 3 </span>
+ <span class="desktopText">
+ Top Text 3
+ </span>
+ <span class="mobileText"> Mobile Text 3 </span>
</div>
<gr-date-formatter> </gr-date-formatter>
</div>
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 80d22d8..a7c84df 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -313,7 +313,7 @@
data-index=${index}
?selected=${index === 0}
?active=${index === 0}
- ?disabled=${link.id && this.disabledIds.includes(link.id)}
+ ?disabled=${!!link.id && this.disabledIds.includes(link.id)}
data-id=${ifDefined(link.id)}
@click=${this.handleItemTap}
@keydown=${(e: KeyboardEvent) => {
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 be70f42..00b67f4 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
@@ -402,7 +402,7 @@
() => html` <div class="email-dropdown" id="editMessageEmailDropdown">Committer Email
<gr-dropdown-list
.items=${this.getEmailDropdownItems()}
- .value=${this.committerEmail}
+ .value=${this.committerEmail ?? ''}
@value-change=${this.setCommitterEmail}
>
</gr-dropdown-list>
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 0df19f9..b5ba204 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -21,6 +21,7 @@
import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
import {SuggestionsProvider} from '../../../api/suggestions';
import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
import {when} from 'lit/directives/when.js';
import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
import {getAppContext} from '../../../services/app-context';
@@ -67,6 +68,10 @@
@state() isChangeAbandoned = false;
+ @state() private thumbUpSelected = false;
+
+ @state() private thumbDownSelected = false;
+
private readonly getConfigModel = resolve(this, configModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
@@ -77,6 +82,8 @@
private readonly reporting = getAppContext().reportingService;
+ private readonly flagsService = getAppContext().flagsService;
+
@state() private previewLoaded = false;
constructor() {
@@ -165,6 +172,8 @@
}
.header .title {
flex: 1;
+ display: flex;
+ align-items: center;
}
.headerMiddle {
display: flex;
@@ -173,6 +182,17 @@
.copyButton {
margin-right: var(--spacing-l);
}
+ .feedback-button[aria-label='Thumb up'] {
+ margin-left: var(--spacing-l);
+ margin-right: 0;
+ }
+ .feedback-button[aria-label='Thumb down'] {
+ margin-left: 0;
+ }
+ .selected {
+ color: var(--selected-foreground);
+ background-color: var(--selected-background);
+ }
`,
];
}
@@ -198,11 +218,44 @@
name="suggestion"
.value=${fix_suggestions}
></gr-endpoint-param
- ><gr-icon
- icon="help"
- title="read documentation"
- ></gr-icon></gr-endpoint-decorator
- ></a>
+ ><gr-icon icon="help" title="read documentation"></gr-icon
+ ></gr-endpoint-decorator>
+ </a>
+ ${when(
+ this.flagsService.isEnabled(
+ KnownExperimentId.ML_SUGGESTED_EDIT_FEEDBACK
+ ),
+ () => html`
+ <gr-button
+ secondary
+ flatten
+ class="action feedback-button ${this.thumbUpSelected
+ ? 'selected'
+ : ''}"
+ aria-label="Thumb up"
+ @click=${this.handleThumbUpClick}
+ >
+ <gr-icon
+ icon="thumb_up"
+ ?filled=${this.thumbUpSelected}
+ ></gr-icon>
+ </gr-button>
+ <gr-button
+ secondary
+ flatten
+ class="action feedback-button ${this.thumbDownSelected
+ ? 'selected'
+ : ''}"
+ aria-label="Thumb down"
+ @click=${this.handleThumbDownClick}
+ >
+ <gr-icon
+ icon="thumb_down"
+ ?filled=${this.thumbDownSelected}
+ ></gr-icon>
+ </gr-button>
+ `
+ )}
</div>
<div class="headerMiddle">
<gr-button
@@ -318,6 +371,24 @@
}
}
+ private handleThumbUpClick() {
+ this.thumbUpSelected = !this.thumbUpSelected;
+ if (this.thumbUpSelected) {
+ this.thumbDownSelected = false;
+ }
+ this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_THUMB_UP);
+ }
+
+ private handleThumbDownClick() {
+ this.thumbDownSelected = !this.thumbDownSelected;
+ if (this.thumbDownSelected) {
+ this.thumbUpSelected = false;
+ }
+ this.reporting.reportInteraction(
+ Interaction.GENERATE_SUGGESTION_THUMB_DOWN
+ );
+ }
+
private isApplyEditDisabled() {
if (this.comment?.patch_set === undefined) return true;
if (this.isChangeMerged) return true;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
index a913adb..b185ca2 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -407,7 +407,7 @@
if (this.account._account_id)
return createDashboardUrl({
type: DashboardType.USER,
- user: `${this.account._account_id}`,
+ user: this.account._account_id,
});
if (this.account.email)
return createDashboardUrl({
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index fdac30e..f048046 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -88,7 +88,7 @@
return html`<div class="container">
<a href=${this.href}>
<gr-limited-text
- .limit=${this.limit}
+ .limit=${this.limit ?? 25}
.text=${this.text}
></gr-limited-text>
</a>
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 81f14da..9ea63dc 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
@@ -7,12 +7,7 @@
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {getAppContext} from '../../../services/app-context';
-import {
- BasePatchSetNum,
- EDIT,
- PatchSetNumber,
- RepoName,
-} from '../../../types/common';
+import {EDIT, PatchSetNumber, RepoName} from '../../../types/common';
import {
DiffLayer,
DiffPreferencesInfo,
@@ -23,7 +18,11 @@
import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
import {resolve} from '../../../models/dependency';
import {highlightServiceToken} from '../../../services/highlight/highlight-service';
-import {FixSuggestionInfo, NumericChangeId} from '../../../api/rest-api';
+import {
+ FixSuggestionInfo,
+ NumericChangeId,
+ RevisionPatchSetNum,
+} from '../../../api/rest-api';
import {changeModelToken} from '../../../models/change/change-model';
import {subscribe} from '../../lit/subscription-controller';
import {DiffPreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -67,7 +66,7 @@
uuid?: string;
@property({type: Number})
- patchSet?: BasePatchSetNum;
+ patchSet?: RevisionPatchSetNum;
// Optional. Used in logging.
@property({type: String})
@@ -334,13 +333,15 @@
errorText,
});
}
- if (res?.ok) {
+ // basePatchNum is from comment patchset and comment cannot be created
+ // in EDIT. RevisionPatchset without EDIT is PatchSetNumber
+ if (res?.ok && basePatchNum !== undefined && basePatchNum !== EDIT) {
this.getNavigation().setUrl(
createChangeUrl({
changeNum,
repo: this.repo!,
patchNum: EDIT,
- basePatchNum,
+ basePatchNum: basePatchNum as PatchSetNumber,
forceReload: !this.hasEdit,
})
);
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 2fd085e..17461c7 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
@@ -298,7 +298,7 @@
putCursorAtEndOnFocus
class=${classMap({noBorder: this.hideBorder})}
.placeholder=${this.placeholder}
- ?disabled=${this.disabled}
+ ?disabled=${!!this.disabled}
.value=${this.text}
.hint=${this.autocompleteHint}
@input=${(e: InputEvent) => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 84e5ffe..287f4da 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -3,7 +3,7 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {ImageInfo} from '../../../types/common';
+import {Base64ImageFile} from '../../../types/common';
import {Side} from '../../../api/diff';
import '../gr-diff-image-viewer/gr-image-viewer';
import {html, LitElement, nothing} from 'lit';
@@ -14,9 +14,9 @@
const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
class GrDiffImageNew extends LitElement {
- @property() baseImage?: ImageInfo;
+ @property() baseImage?: Base64ImageFile;
- @property() revisionImage?: ImageInfo;
+ @property() revisionImage?: Base64ImageFile;
@property() automaticBlink = false;
@@ -53,9 +53,9 @@
}
class GrDiffImageOld extends LitElement {
- @property() baseImage?: ImageInfo;
+ @property() baseImage?: Base64ImageFile;
- @property() revisionImage?: ImageInfo;
+ @property() revisionImage?: Base64ImageFile;
@property() columnCount = 0;
@@ -198,8 +198,8 @@
}
}
-function imageSrc(image?: ImageInfo): string {
- return image && IMAGE_MIME_PATTERN.test(image.type)
+function imageSrc(image?: Base64ImageFile): string {
+ return image?.type && IMAGE_MIME_PATTERN.test(image.type)
? `data:${image.type};base64,${image.body}`
: '';
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index 183f3d2..0673621 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -41,14 +41,14 @@
import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
import {assert} from '../../../utils/common-util';
import {countLines, isImageDiff} from '../../../utils/diff-util';
-import {BlameInfo, ImageInfo} from '../../../types/common';
+import {Base64ImageFile, BlameInfo} from '../../../types/common';
import {fire} from '../../../utils/event-util';
import {CommentRange} from '../../../api/rest-api';
export interface DiffState {
diff?: DiffInfo;
- baseImage?: ImageInfo;
- revisionImage?: ImageInfo;
+ baseImage?: Base64ImageFile;
+ revisionImage?: Base64ImageFile;
path?: string;
renderPrefs: RenderPreferences;
diffPrefs: DiffPreferencesInfo;
@@ -101,12 +101,12 @@
countLines(diff, Side.LEFT)
);
- readonly baseImage$: Observable<ImageInfo | undefined> = select(
+ readonly baseImage$: Observable<Base64ImageFile | undefined> = select(
this.state$,
diffState => diffState.baseImage
);
- readonly revisionImage$: Observable<ImageInfo | undefined> = select(
+ readonly revisionImage$: Observable<Base64ImageFile | undefined> = select(
this.state$,
diffState => diffState.revisionImage
);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
index 9aa8f52..8176df2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
@@ -15,7 +15,7 @@
import '../gr-diff-builder/gr-diff-section';
import '../gr-diff-builder/gr-diff-row';
import {FULL_CONTEXT, FullContext, isResponsive} from './gr-diff-utils';
-import {ImageInfo} from '../../../types/common';
+import {Base64ImageFile} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {
createDefaultDiffPrefs,
@@ -53,9 +53,9 @@
@state() diff?: DiffInfo;
- @state() baseImage?: ImageInfo;
+ @state() baseImage?: Base64ImageFile;
- @state() revisionImage?: ImageInfo;
+ @state() revisionImage?: Base64ImageFile;
@state() diffPrefs: DiffPreferencesInfo = createDefaultDiffPrefs();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
index d2a96ae..758894f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
@@ -17,7 +17,7 @@
} from '../../../api/diff';
import {query, queryAndAssert, waitUntil} from '../../../test/test-utils';
import {waitForEventOnce} from '../../../utils/event-util';
-import {ImageInfo} from '../../../types/common';
+import {Base64ImageFile} from '../../../types/common';
import {assert, fixture, html} from '@open-wc/testing';
import {createDefaultDiffPrefs} from '../../../constants/constants';
import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
@@ -2815,8 +2815,8 @@
});
suite('image diffs', () => {
- let mockFile1: ImageInfo;
- let mockFile2: ImageInfo;
+ let mockFile1: Base64ImageFile;
+ let mockFile2: Base64ImageFile;
setup(() => {
mockFile1 = {
body:
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 3bbf5b6..c5fef11 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -27,7 +27,7 @@
isResponsive,
isThreadEl,
} from '../gr-diff/gr-diff-utils';
-import {BlameInfo, ImageInfo} from '../../../types/common';
+import {Base64ImageFile, BlameInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
import {CoverageRange, DiffLayer, isDefined} from '../../../types/types';
@@ -223,10 +223,10 @@
diff?: DiffInfo;
@property({type: Object})
- baseImage?: ImageInfo;
+ baseImage?: Base64ImageFile;
@property({type: Object})
- revisionImage?: ImageInfo;
+ revisionImage?: Base64ImageFile;
@property({type: String})
errorMessage: string | null = null;
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
index 53aede2..c1f1d52 100644
--- a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
@@ -49,7 +49,7 @@
<gr-icon
class="icon"
icon=${icon}
- ?filled=${this.filled}
+ ?filled=${!!this.filled}
aria-hidden="true"
></gr-icon>
<slot></slot>
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index c959513..fd99123 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -43,7 +43,7 @@
viewableToAll?: boolean;
section?: string;
capability?: string;
- target?: string | null;
+ target?: '_blank' | '_parent' | '_self' | '_top' | null;
subsection?: SubsectionInterface;
children?: SubsectionInterface[];
}
@@ -150,7 +150,7 @@
// Append top-level links that are defined by plugins.
links.push(
- ...getAdminMenuLinks().map((link: MenuLink) => {
+ ...getAdminMenuLinks().map((link: MenuLink): NavLink => {
return {
url: link.url,
name: link.text,
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index f5fc8b4..f9e60b4 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -5,7 +5,7 @@
*/
import {RepoName} from '../../api/rest-api';
import {GerritView} from '../../services/router/router-model';
-import {DashboardId} from '../../types/common';
+import {DashboardId, UserId} from '../../types/common';
import {DashboardSection} from '../../utils/dashboard-util';
import {encodeURL, getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
@@ -38,7 +38,7 @@
type: DashboardType;
project?: RepoName;
dashboard?: DashboardId;
- user?: string;
+ user?: UserId | 'self';
sections?: DashboardSection[];
title?: string;
}
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 47f4868a..0d4d0ba 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '@open-wc/testing';
-import {RepoName} from '../../api/rest-api';
+import {EmailAddress, RepoName} from '../../api/rest-api';
import {GerritView} from '../../services/router/router-model';
import '../../test/common-test-setup';
import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
@@ -56,8 +56,11 @@
test('user dashboard', () => {
assert.equal(
- createDashboardUrl({type: DashboardType.USER, user: 'user'}),
- '/dashboard/user'
+ createDashboardUrl({
+ type: DashboardType.USER,
+ user: 'user@email.com' as EmailAddress,
+ }),
+ '/dashboard/user@email.com'
);
});
@@ -94,13 +97,13 @@
test('custom user dashboard, with title', () => {
const state = {
type: DashboardType.CUSTOM,
- user: 'user',
+ user: 'user@email.com' as EmailAddress,
sections: [{name: 'name', query: 'query'}],
title: 'custom dashboard',
};
assert.equal(
createDashboardUrl(state),
- '/dashboard/user?name=query&title=custom+dashboard'
+ '/dashboard/user@email.com?name=query&title=custom+dashboard'
);
});
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 86d8a39..eecb898 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,4 +25,6 @@
PARALLEL_DASHBOARD_REQUESTS = 'UiFeature__parallel_dashboard_requests',
GET_AI_FIX = 'UiFeature__get_ai_fix',
GET_AI_PROMPT = 'UiFeature__get_ai_prompt',
+ ML_SUGGESTED_EDIT_UNCHECK_BY_DEFAULT = 'UiFeature__ml_suggested_edit_uncheck_by_default',
+ ML_SUGGESTED_EDIT_FEEDBACK = 'UiFeature__ml_suggested_edit_feedback',
}
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 98bf231..2dceb45 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -20,6 +20,7 @@
AccountInfo,
AccountStateInfo,
ActionNameToActionInfoMap,
+ AuthTokenInfo,
Base64File,
Base64FileContent,
Base64ImageFile,
@@ -52,7 +53,6 @@
EditPreferencesInfo,
EmailAddress,
EmailInfo,
- EncodedGroupId,
FileNameToFileInfoMap,
FilePathToDiffInfoMap,
GitRef,
@@ -76,7 +76,6 @@
NumericChangeId,
PARENT,
ParsedJSON,
- Password,
PatchRange,
PatchSetNum,
PluginInfo,
@@ -580,7 +579,7 @@
}
getGroupAuditLog(
- group: EncodedGroupId,
+ group: GroupId,
errFn?: ErrorCallback
): Promise<GroupAuditEventInfo[] | undefined> {
return this._restApiHelper.fetchCacheJSON({
@@ -3135,24 +3134,34 @@
}) as unknown as Promise<Hashtag[] | undefined>;
}
- deleteAccountHttpPassword(): Promise<Response> {
+ getAccountAuthTokens(): Promise<AuthTokenInfo[] | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url: '/accounts/self/tokens',
+ reportUrlAsIs: true,
+ }) as Promise<AuthTokenInfo[] | undefined>;
+ }
+
+ deleteAccountAuthToken(tokenId: string): Promise<Response> {
return this._restApiHelper.fetch({
fetchOptions: {method: HttpMethod.DELETE},
- url: '/accounts/self/password.http',
+ url: `/accounts/self/tokens/${tokenId}`,
reportUrlAsIs: true,
reportServerError: true,
});
}
- generateAccountHttpPassword(): Promise<Password | undefined> {
+ generateAccountAuthToken(
+ tokenId: string,
+ lifetime: string
+ ): Promise<AuthTokenInfo | undefined> {
return this._restApiHelper.fetchJSON({
fetchOptions: getFetchOptions({
method: HttpMethod.PUT,
- body: {generate: true},
+ body: {id: tokenId, lifetime},
}),
- url: '/accounts/self/password.http',
+ url: `/accounts/self/tokens/${tokenId}`,
reportUrlAsIs: true,
- }) as Promise<unknown> as Promise<Password>;
+ }) as Promise<unknown> as Promise<AuthTokenInfo>;
}
getAccountSSHKeys() {
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 7a1ecfe..f4b941c 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
@@ -1624,15 +1624,15 @@
);
});
- test('generateAccountHttpPassword', async () => {
+ test('generateAccountAuthToken', async () => {
const fetchStub = sinon
.stub(element._restApiHelper, 'fetchJSON')
.resolves();
- await element.generateAccountHttpPassword();
+ await element.generateAccountAuthToken('token1', '3d');
assert.isTrue(fetchStub.calledOnce);
assert.deepEqual(
JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
- {generate: true}
+ {id: 'token1', lifetime: '3d'}
);
});
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 12505ba..985e091 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
@@ -12,6 +12,7 @@
AccountInfo,
AccountStateInfo,
ActionNameToActionInfoMap,
+ AuthTokenInfo,
Base64FileContent,
BasePatchSetNum,
BlameInfo,
@@ -38,7 +39,6 @@
EditPreferencesInfo,
EmailAddress,
EmailInfo,
- EncodedGroupId,
FileNameToFileInfoMap,
FilePathToDiffInfoMap,
GitRef,
@@ -59,7 +59,6 @@
MergeableInfo,
NameToProjectInfoMap,
NumericChangeId,
- Password,
PatchRange,
PatchSetNum,
PluginInfo,
@@ -532,7 +531,14 @@
saveAccountAgreement(name: ContributorAgreementInput): Promise<Response>;
- generateAccountHttpPassword(): Promise<Password | undefined>;
+ getAccountAuthTokens(): Promise<AuthTokenInfo[] | undefined>;
+
+ deleteAccountAuthToken(tokenId: string): Promise<Response>;
+
+ generateAccountAuthToken(
+ tokenId: string,
+ lifetime: string
+ ): Promise<AuthTokenInfo | undefined>;
setAccountName(name: string): Promise<void>;
@@ -604,7 +610,7 @@
): Promise<Response | undefined>;
getGroupAuditLog(
- group: EncodedGroupId,
+ group: GroupId,
errFn?: ErrorCallback
): Promise<GroupAuditEventInfo[] | undefined>;
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 b86c9e1..fba5598 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -11,6 +11,7 @@
AccountInfo,
AccountStateInfo,
ActionNameToActionInfoMap,
+ AuthTokenInfo,
Base64FileContent,
BlameInfo,
BranchInfo,
@@ -40,7 +41,6 @@
MergeableInfo,
NameToProjectInfoMap,
NumericChangeId,
- Password,
PluginInfo,
PreferencesInfo,
PreferencesInput,
@@ -166,8 +166,16 @@
return Promise.resolve(new Response());
},
finalize(): void {},
- generateAccountHttpPassword(): Promise<Password | undefined> {
- return Promise.resolve('asdf');
+ getAccountAuthTokens(): Promise<AuthTokenInfo[] | undefined> {
+ return Promise.resolve([{id: 'tokenId', token: 'asdf'}]);
+ },
+ deleteAccountAuthToken(): Promise<Response> {
+ return Promise.resolve(new Response());
+ },
+ generateAccountAuthToken(
+ tokenId: string
+ ): Promise<AuthTokenInfo | undefined> {
+ return Promise.resolve({id: tokenId, token: 'asdf'});
},
getAccount(): Promise<AccountDetailInfo | undefined> {
return Promise.resolve(createAccountDetailWithId(1));
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 482a803..eab8759 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -62,9 +62,9 @@
"name": "ts-lit-plugin",
"strict": true,
"rules": {
- "no-unknown-tag-name": "error",
+ "no-unknown-tag-name": "off",
"no-unclosed-tag": "error",
- "no-unknown-property": "error",
+ "no-unknown-property": "off",
"no-unintended-mixed-binding": "error",
"no-invalid-boolean-binding": "error",
"no-expressionless-property-binding": "error",
@@ -72,12 +72,14 @@
"no-boolean-in-attribute-binding": "error",
"no-complex-attribute-binding": "error",
"no-nullable-attribute-binding": "error",
- "no-incompatible-type-binding": "error",
+ "no-incompatible-type-binding": "off",
"no-invalid-directive-binding": "error",
- "no-incompatible-property-type": "error",
+ "no-incompatible-property-type": "off",
"no-unknown-property-converter": "error",
"no-invalid-attribute-name": "error",
- "no-invalid-tag-name": "error"
+ "no-invalid-tag-name": "error",
+ "no-property-visibility-mismatch": "off",
+ "no-unknown-attribute": "off",
}
}
]
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 6c577c1..4123813 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -250,9 +250,6 @@
export type LabelName = BrandType<string, '_labelName'>;
-// The Encoded UUID of the group
-export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
-
export type UserId = AccountId | GroupId | EmailAddress;
export type DiffPageSidebar = 'NONE' | `plugin-${string}`;
@@ -463,7 +460,7 @@
url?: string;
name?: string;
external?: boolean;
- target?: string | null;
+ target?: '_blank' | '_parent' | '_self' | '_top' | null;
download?: boolean;
id?: string;
tooltip?: string;
@@ -518,7 +515,7 @@
export interface TopMenuItemInfo {
url: string;
name: string;
- target?: string;
+ target?: '_blank' | '_parent' | '_self' | '_top' | null;
id?: string;
}
@@ -705,18 +702,6 @@
}
/**
- * Images are retrieved by using the file content API and the body is just the
- * HTML response.
- * TODO(TS): where is the source of this type ? I don't find it in doc
- */
-export interface ImageInfo {
- body: string;
- type: string;
- _name?: string;
- _expectedType?: string;
-}
-
-/**
* The SubmitRequirementInfo entity describes a submit requirement.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-requirement-info
*/
@@ -1116,8 +1101,8 @@
* There is no RestAPI interface for it
*/
export interface Base64ImageFile extends Base64File {
- _expectedType: string;
- _name: string;
+ _expectedType?: string;
+ _name?: string;
}
/**
@@ -1285,7 +1270,17 @@
export type RequestPayload = string | object;
-export type Password = string;
+export interface AuthTokenInput {
+ id?: string;
+ token?: string;
+ lifetime?: string;
+}
+
+export interface AuthTokenInfo {
+ id: string;
+ token?: string;
+ expiration?: Timestamp;
+}
/**
* The BranchInput entity contains information for the creation of a new branch
diff --git a/polygerrit-ui/app/utils/dashboard-util.ts b/polygerrit-ui/app/utils/dashboard-util.ts
index 6087e2c..dd25ff14 100644
--- a/polygerrit-ui/app/utils/dashboard-util.ts
+++ b/polygerrit-ui/app/utils/dashboard-util.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {ChangeConfigInfo, ChangeInfo} from '../api/rest-api';
+import {UserId} from '../types/common';
export interface DashboardSection {
name: string;
@@ -100,7 +101,7 @@
];
export function getUserDashboard(
- user = 'self',
+ user: UserId | 'self' = 'self',
sections = DEFAULT_SECTIONS,
title = ''
): UserDashboard {
@@ -110,7 +111,8 @@
return {
...section,
name: section.name,
- query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+ // user is usually account_id which is type number
+ query: section.query.replace(USER_PLACEHOLDER_PATTERN, String(user)),
};
});
return {title, sections};
diff --git a/polygerrit-ui/app/utils/dashboard-util_test.ts b/polygerrit-ui/app/utils/dashboard-util_test.ts
index 40e4ad9..6f53eb2 100644
--- a/polygerrit-ui/app/utils/dashboard-util_test.ts
+++ b/polygerrit-ui/app/utils/dashboard-util_test.ts
@@ -6,6 +6,7 @@
import {assert} from '@open-wc/testing';
import '../test/common-test-setup';
import {getUserDashboard} from './dashboard-util';
+import {EmailAddress} from '../api/rest-api';
suite('gr-navigation tests', () => {
suite('_getUserDashboard', () => {
@@ -38,12 +39,16 @@
});
test('dashboard for other user', () => {
- const dashboard = getUserDashboard('user', sections, 'title');
+ const dashboard = getUserDashboard(
+ 'user@email.com' as EmailAddress,
+ sections,
+ 'title'
+ );
assert.deepEqual(dashboard, {
title: 'title',
sections: [
{name: 'section 1', query: 'query 1'},
- {name: 'section 2', query: 'query 2 for user'},
+ {name: 'section 2', query: 'query 2 for user@email.com'},
{
name: 'section 4',
query: 'query 4',
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png
new file mode 100644
index 0000000..01c139a
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.png
new file mode 100644
index 0000000..cf6fc7a
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.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
new file mode 100644
index 0000000..2c81cf5
--- /dev/null
+++ 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
new file mode 100644
index 0000000..4af5816
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png
new file mode 100644
index 0000000..d6b63bf
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png
new file mode 100644
index 0000000..e9cfdd2
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png
new file mode 100644
index 0000000..cdfebe6
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png
new file mode 100644
index 0000000..a3c2cda
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png
new file mode 100644
index 0000000..f1db32b
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png
new file mode 100644
index 0000000..c8c9517
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png
new file mode 100644
index 0000000..5b7b70f
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.png
new file mode 100644
index 0000000..96bea54
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.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
new file mode 100644
index 0000000..5553c10
--- /dev/null
+++ 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
new file mode 100644
index 0000000..d42627f
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png
Binary files differ
diff --git a/proto/entities.proto b/proto/entities.proto
index b1e0603..4f222c2 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -116,14 +116,26 @@
}
// Serialized form of com.google.gerrit.entities.PatchSet.Conflicts.
-// Next ID: 5
+// Next ID: 7
message Conflicts {
- reserved 4;
+ optional ObjectId base = 4;
optional ObjectId ours = 1;
optional ObjectId theirs = 2;
optional bool containsConflicts = 3;
+ optional string merge_strategy = 5;
+ optional NoMergeBaseReason noBaseReason = 6;
}
+// Serialized form of com.google.gerrit.extensions.common.NoMergeBaseReason.
+// Next ID: 3
+enum NoMergeBaseReason {
+ HISTORIC_DATA_WITHOUT_BASE = 0;
+ NO_COMMON_ANCESTOR = 1;
+ COMPUTED_BASE = 2;
+ ONE_SIDED_MERGE_STRATEGY = 3;
+ NO_MERGE_PERFORMED = 4;
+ }
+
// Serialized form of com.google.gerrit.extensions.common.MergeInput.
// Next ID: 5
message MergeInput {
@@ -141,7 +153,7 @@
}
// Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput.
-// Next ID: 8
+// Next ID: 9
message AccountInput {
optional string username = 1;
optional string name = 2;
@@ -150,6 +162,15 @@
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;
}
// Serialized form of com.google.gerrit.extensions.client.ListChangesOption.
@@ -462,7 +483,7 @@
// 0-based
optional int32 end_char = 4;
}
-
+
// Next Id: 5
message InFilePosition {
optional string file_path = 1;
diff --git a/resources/com/google/gerrit/server/mail/AuthTokenExpired.soy b/resources/com/google/gerrit/server/mail/AuthTokenExpired.soy
new file mode 100644
index 0000000..66fb124
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AuthTokenExpired.soy
@@ -0,0 +1,52 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template.AuthTokenExpired}
+
+/**
+ * The .AuthTokenExpired template will determine the contents of the email related to
+ * the token being expired.
+ */
+{template AuthTokenExpired kind="text"}
+ {@param email: ?}
+ The authentication token with id "{$email.tokenId}" on Gerrit Code Review at
+ {sp}{$email.gerritHost} has expired at {$email.expirationDate}.
+
+ {\n}
+ {\n}
+
+ To generate a new token visit
+ {\n}
+ {$email.authTokenSettingsUrl}
+ {\n}
+ {if $email.userNameEmail}
+ (while signed in as {$email.userNameEmail})
+ {else}
+ (while signed in as {$email.email})
+ {/if}
+
+ {\n}
+ {\n}
+
+ If clicking the link above does not work, copy and paste the URL in a new
+ browser window instead.
+
+ {\n}
+ {\n}
+
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AuthTokenExpiredHtml.soy b/resources/com/google/gerrit/server/mail/AuthTokenExpiredHtml.soy
new file mode 100644
index 0000000..3b841d0
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AuthTokenExpiredHtml.soy
@@ -0,0 +1,41 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template.AuthTokenExpiredHtml}
+
+{template AuthTokenExpiredHtml}
+ {@param email: ?}
+ <p>
+ The authentication token with id "{$email.tokenId}" on Gerrit Code Review
+ at {$email.gerritHost} has expired at {$email.expirationDate}.
+ </p>
+
+ <p>
+ To generate a new token visit{sp}
+ <a href="{$email.authTokenSettingsUrl}">this link</a>
+ {sp}
+ {if $email.userNameEmail}
+ (while signed in as {$email.userNameEmail})
+ {else}
+ (while signed in as {$email.email})
+ {/if}.
+ </p>
+
+ <p>
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+ </p>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AuthTokenUpdate.soy b/resources/com/google/gerrit/server/mail/AuthTokenUpdate.soy
new file mode 100644
index 0000000..d2df39c
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AuthTokenUpdate.soy
@@ -0,0 +1,55 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template.AuthTokenUpdate}
+
+/**
+ * The .AuthTokenUpdate template will determine the contents of the email related to
+ * adding, changing or deleting the token.
+ */
+{template AuthTokenUpdate kind="text"}
+ {@param email: ?}
+ The authentication token with id "{$email.tokenId}" was {$email.operation}{sp}
+ on Gerrit Code Review at {sp}{$email.gerritHost}.
+
+ If this is not expected, please contact your Gerrit Administrators
+ immediately.
+
+ {\n}
+ {\n}
+
+ You can also manage your authentication tokens by visiting
+ {\n}
+ {$email.authTokenSettingsUrl}
+ {\n}
+ {if $email.userNameEmail}
+ (while signed in as {$email.userNameEmail})
+ {else}
+ (while signed in as {$email.email})
+ {/if}
+
+ {\n}
+ {\n}
+
+ If clicking the link above does not work, copy and paste the URL in a new
+ browser window instead.
+
+ {\n}
+ {\n}
+
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AuthTokenUpdateHtml.soy b/resources/com/google/gerrit/server/mail/AuthTokenUpdateHtml.soy
new file mode 100644
index 0000000..f50ef63
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AuthTokenUpdateHtml.soy
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template.AuthTokenUpdateHtml}
+
+{template AuthTokenUpdateHtml}
+ {@param email: ?}
+ <p>
+ The authentication tokens with id "{$email.tokenId}" was {$email.operation}{sp}
+ on Gerrit Code Review at {$email.gerritHost}.
+ </p>
+
+ <p>
+ If this is not expected, please contact your Gerrit Administrators
+ immediately.
+ </p>
+
+ <p>
+ You can also manage your authentication tokens by following{sp}
+ <a href="{$email.authTokenSettingsUrl}">this link</a>
+ {sp}
+ {if $email.userNameEmail}
+ (while signed in as {$email.userNameEmail})
+ {else}
+ (while signed in as {$email.email})
+ {/if}.
+ </p>
+
+ <p>
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+ </p>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AuthTokenWillExpire.soy b/resources/com/google/gerrit/server/mail/AuthTokenWillExpire.soy
new file mode 100644
index 0000000..58242ee
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AuthTokenWillExpire.soy
@@ -0,0 +1,52 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template.AuthTokenWillExpire}
+
+/**
+ * The AuthTokenWillExpire template will determine the contents of the email related to
+ * the token about to expire.
+ */
+{template AuthTokenWillExpire kind="text"}
+ {@param email: ?}
+ The authentication token with id "{$email.tokenId}" on Gerrit Code Review at
+ {sp}{$email.gerritHost} will expire on {$email.expirationDate}.
+
+ {\n}
+ {\n}
+
+ To generate a new token visit
+ {\n}
+ {$email.authTokenSettingsUrl}
+ {\n}
+ {if $email.userNameEmail}
+ (while signed in as {$email.userNameEmail})
+ {else}
+ (while signed in as {$email.email})
+ {/if}
+
+ {\n}
+ {\n}
+
+ If clicking the link above does not work, copy and paste the URL in a new
+ browser window instead.
+
+ {\n}
+ {\n}
+
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AuthTokenWillExpireHtml.soy b/resources/com/google/gerrit/server/mail/AuthTokenWillExpireHtml.soy
new file mode 100644
index 0000000..f29981b
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AuthTokenWillExpireHtml.soy
@@ -0,0 +1,41 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template.AuthTokenWillExpireHtml}
+
+{template AuthTokenWillExpireHtml}
+ {@param email: ?}
+ <p>
+ The authentication token with id "{$email.tokenId}" on Gerrit Code Review
+ at {$email.gerritHost} will expire on {$email.expirationDate}.
+ </p>
+
+ <p>
+ To generate a new token visit{sp}
+ <a href="{$email.authTokenSettingsUrl}">this link</a>
+ {sp}
+ {if $email.userNameEmail}
+ (while signed in as {$email.userNameEmail})
+ {else}
+ (while signed in as {$email.email})
+ {/if}.
+ </p>
+
+ <p>
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+ </p>
+{/template}