Merge branch 'stable-3.12'
* stable-3.12:
Revert "Invoke changeServerId() function when calculating virtual Id"
Update git submodules
Fix ref existance check in CREATE ReceiveCommits
Add "@lezer/highlight" package
Find changes by change number only if imported server IDs are configured
AbtractFakeIndex: use changenumber instead of legacy_is_str
Update git submodules
Disable robot comments by default
Revert "Lookup imported change by change number in ChangeFinder::find"
Set version to 3.12.0-SNAPSHOT
Set version to 3.12.0-rc1
GitFileDiffCacheImpl: cancel tasks failing due to timeout or interrupt
Update git submodules
feat(Get AI Fix): Attach fixSuggestion to automatic reply when applying Ai Fix
Disable format warning on current editing line
AccountIndexerImpl: explicitly use AccountCacheImpl
Remove net.i2p.crypto:eddsa
Allow opening edit preference in editor view
Respect the `enableRobotComments` server config in the UI
Set version to 3.12.0-SNAPSHOT
Set version to 3.12.0-rc0
Set version to 3.11.3-SNAPSHOT
Set version to 3.11.2
Set version to 3.10.6-SNAPSHOT
Set version to 3.10.5
Set version to 3.9.11-SNAPSHOT
Set version to 3.9.10
Release-Notes: skip
Change-Id: Ied6c827d57a69adc1b6759104a57480ada9dcf0c
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index b0149fe..8b2c9d4 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -6,7 +6,6 @@
[[new-commit-validation]]
== New commit validation
-
Plugins implementing the `CommitValidationListener` interface can
perform additional validation checks against new commits.
@@ -21,6 +20,13 @@
Out of the box, Gerrit includes a plugin that checks the length of the
subject and body lines of commit messages on uploaded commits.
+[[push-options-validation]]
+== Push options validation
+
+Plugins implementing the `PushOptionsValidator` interface can validate push
+options. For example, they can reject options (or a combination of options) or
+emit a warning when a deprecated option is being used.
+
[plugin-push-options]]
=== Plugin push options
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 596ee96..2d48d81 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -8757,8 +8757,7 @@
|=================================
|Field Name ||Description
|`message` |optional|
-Message to be added as review comment to the change when reverting the
-change.
+Commit message of the revert commit. If not specified, a default commit message is set.
|`notify` |optional|
Notify handling that defines to whom email notifications should be sent
for reverting the change. +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 7cc9e29..c5bee19 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -219,6 +219,20 @@
Additional options may be specified when pushing changes.
+[[custom_keyed_values]]
+==== Custom Keyed Values
+
+Uploaders can specify custom keyed values for the change using this push option.
+The value can be set more than once to apply multiple custom keyed values.
+Each key,value pair must be separate by a colon (':') otherwise the push fails.
+
+----
+ git push ssh://bot@git.example.com:29418/kernel/common HEAD:refs/for/master -o custom-keyed-value=foo:bar custom-keyed-value=hello:world
+----
+
+Note: Setting the `custom-keyed-value` option in the refname
+(`%custom-keyed-values=foo:bar`) is **not** supported.
+
[[notify]]
==== Email Notifications
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index f094b48..4cbaea2 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -53,6 +53,7 @@
import com.google.gerrit.server.change.ReviewerSuggestion;
import com.google.gerrit.server.config.ProjectConfigEntry;
import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.receive.PushOptionsValidator;
import com.google.gerrit.server.git.validators.CommitValidationInfoListener;
import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
@@ -118,6 +119,7 @@
private final DynamicSet<ValidationOptionsListener> validationOptionsListeners;
private final DynamicSet<CommitValidationInfoListener> commitValidationInfoListeners;
private final DynamicSet<RetryListener> retryListeners;
+ private final DynamicSet<PushOptionsValidator> pushOptionsValidators;
private final DynamicMap<ChangeHasOperandFactory> hasOperands;
private final DynamicMap<ChangeIsOperandFactory> isOperands;
@@ -172,6 +174,7 @@
DynamicSet<ValidationOptionsListener> validationOptionsListeners,
DynamicSet<CommitValidationInfoListener> commitValidationInfoListeners,
DynamicSet<RetryListener> retryListeners,
+ DynamicSet<PushOptionsValidator> pushOptionsValidator,
DynamicMap<ReviewerSuggestion> reviewerSuggestions) {
this.accountIndexedListeners = accountIndexedListeners;
this.changeIndexedListeners = changeIndexedListeners;
@@ -219,6 +222,7 @@
this.validationOptionsListeners = validationOptionsListeners;
this.commitValidationInfoListeners = commitValidationInfoListeners;
this.retryListeners = retryListeners;
+ this.pushOptionsValidators = pushOptionsValidator;
this.reviewerSuggestions = reviewerSuggestions;
}
@@ -432,6 +436,11 @@
}
@CanIgnoreReturnValue
+ public Registration add(PushOptionsValidator pushOptionsValidator) {
+ return add(pushOptionsValidators, pushOptionsValidator);
+ }
+
+ @CanIgnoreReturnValue
public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
return add(capabilityDefinitions, capabilityDefinition, exportName);
}
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 1f2f151..4d926b8 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -31,7 +31,9 @@
public abstract class Permission implements Comparable<Permission> {
public static final String ABANDON = "abandon";
public static final String ADD_PATCH_SET = "addPatchSet";
+ // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_create
public static final String CREATE = "create";
+ // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_create_signed
public static final String CREATE_SIGNED_TAG = "createSignedTag";
public static final String CREATE_TAG = "createTag";
public static final String DELETE = "delete";
@@ -47,8 +49,10 @@
public static final String LABEL_AS = "labelAs-";
public static final String REMOVE_LABEL = "removeLabel-";
public static final String OWNER = "owner";
+ // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_push
public static final String PUSH = "push";
public static final String PUSH_MERGE = "pushMerge";
+ // https://gerrit-review.googlesource.com/Documentation/access-control.html#category_read
public static final String READ = "read";
public static final String REBASE = "rebase";
public static final String REMOVE_REVIEWER = "removeReviewer";
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index 1ccbd4f..63a118a 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -17,6 +17,35 @@
import com.google.gerrit.extensions.restapi.DefaultInput;
import java.util.Map;
+/**
+ * Input for creating a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-input
+ *
+ * <p>This class holds the data needed to create a new branch, such as the revision to base the
+ * branch on and whether to create an empty commit.
+ *
+ * <p>The following properties can be set:
+ *
+ * <ul>
+ * <li>{@code revision}: The base revision of the new branch. If not set and create_empty_commit
+ * is true the branch is created with an empty initial commit. If not set and
+ * create_empty_commit is false or unset HEAD will be used as base revision.
+ * <li>{@code createEmptyCommit}: Whether the branch should be created with an empty initial
+ * commit. It is not possible to create a branch on a project with no commits. Cannot be used
+ * in combination with setting a revision. Can be used to review the initial content of a
+ * branch (create the branch with an empty initial commit, make a second commit with the
+ * initial content, e.g. by merging in another branch, and push the commit for review)..
+ * <li>{@code ref}: The name of the branch. The prefix refs/heads/ can be omitted. If set, must
+ * match the branch ID in the URL.
+ * <li>{@code validationOptions}: Map with key-value pairs that are forwarded as options to the
+ * ref operation validation listeners (e.g. can be used to skip certain validations). Which
+ * validation options are supported depends on the installed ref operation validation
+ * listeners. Gerrit core doesn’t support any validation options, but ref operation validation
+ * listeners that are implemented in plugins may. Please refer to the documentation of the
+ * installed plugins to learn whether they support validation options. Unknown validation
+ * options are silently ignored.
+ * </ul>
+ */
public class BranchInput {
@DefaultInput public String revision;
public boolean createEmptyCommit;
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index 14ced50..192d9d3 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -20,6 +20,8 @@
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
import com.google.gerrit.entities.ProjectWatchKey;
@@ -177,6 +179,11 @@
|| !this.getUpdatedExternalIds().isEmpty();
}
+ @UsedAt(Project.GOOGLE)
+ public boolean isEmpty() {
+ return this.equals(AccountDelta.builder().build());
+ }
+
/**
* Class to build an {@link AccountDelta}.
*
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 787f036..c228ebe 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -14,25 +14,22 @@
package com.google.gerrit.server.change;
-import org.eclipse.jgit.nls.NLS;
-import org.eclipse.jgit.nls.TranslationBundle;
+public class ChangeMessages {
+ public static String revertChangeDefaultMessage = "Revert \"{0}\"\n\nThis reverts commit {1}.";
+ public static String revertSubmissionDefaultMessage = "This reverts commit {0}.";
+ public static String revertSubmissionUserMessage = "Revert \"{0}\"\n\n{1}";
+ public static String revertSubmissionOfRevertSubmissionUserMessage = "Revert^{0} \"{1}\"\n\n{2}";
-public class ChangeMessages extends TranslationBundle {
- public static ChangeMessages get() {
- return NLS.getBundleFor(ChangeMessages.class);
- }
+ public static String reviewerCantSeeChange = "{0} does not have permission to see this change";
+ public static String reviewerInvalid = "{0} is not a valid user identifier";
+ public static String reviewerNotFoundUserOrGroup =
+ "{0} does not identify a registered user or group";
- public String revertChangeDefaultMessage;
- public String revertSubmissionDefaultMessage;
- public String revertSubmissionUserMessage;
- public String revertSubmissionOfRevertSubmissionUserMessage;
-
- public String reviewerCantSeeChange;
- public String reviewerInvalid;
- public String reviewerNotFoundUserOrGroup;
-
- public String groupRemovalIsNotAllowed;
- public String groupIsNotAllowed;
- public String groupHasTooManyMembers;
- public String groupManyMembersConfirmation;
+ public static String groupRemovalIsNotAllowed =
+ "Groups can't be removed from reviewers, so can't remove {0}.";
+ public static String groupIsNotAllowed = "The group {0} cannot be added as reviewer.";
+ public static String groupHasTooManyMembers =
+ "The group {0} has too many members to add them all as reviewers.";
+ public static String groupManyMembersConfirmation =
+ "The group {0} has {1} members. Do you want to add them all as reviewers?";
}
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 09bfcd3..6d454ae 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -314,7 +314,7 @@
return fail(
input,
FailureType.OTHER,
- MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
+ MessageFormat.format(ChangeMessages.reviewerCantSeeChange, input.reviewer));
}
@Nullable
@@ -340,7 +340,7 @@
return fail(
input,
FailureType.NOT_FOUND,
- MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
+ MessageFormat.format(ChangeMessages.reviewerNotFoundUserOrGroup, input.reviewer));
}
return null;
}
@@ -349,14 +349,14 @@
return fail(
input,
FailureType.OTHER,
- MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+ MessageFormat.format(ChangeMessages.groupIsNotAllowed, group.getName()));
}
if (input.state().equals(REMOVED)) {
return fail(
input,
FailureType.OTHER,
- MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
+ MessageFormat.format(ChangeMessages.groupRemovalIsNotAllowed, group.getName()));
}
Set<Account> reviewers = new HashSet<>();
@@ -376,7 +376,7 @@
return fail(
input,
FailureType.OTHER,
- MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
+ MessageFormat.format(ChangeMessages.groupHasTooManyMembers, group.getName()));
}
// if maxWithoutCheck is set to 0, we never ask for confirmation
@@ -391,7 +391,7 @@
FailureType.OTHER,
true,
MessageFormat.format(
- ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
+ ChangeMessages.groupManyMembersConfirmation, group.getName(), members.size()));
}
for (Account member : members) {
@@ -413,7 +413,7 @@
return fail(
input,
FailureType.OTHER,
- MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
+ MessageFormat.format(ChangeMessages.reviewerCantSeeChange, input.reviewer));
}
Address adr = Address.tryParse(input.reviewer);
@@ -421,7 +421,7 @@
return fail(
input,
FailureType.NOT_FOUND,
- MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
+ MessageFormat.format(ChangeMessages.reviewerInvalid, input.reviewer));
}
return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
}
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 43c4933..9448725 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -14,37 +14,30 @@
package com.google.gerrit.server.config;
-import org.eclipse.jgit.nls.NLS;
-import org.eclipse.jgit.nls.TranslationBundle;
-
-public class CapabilityConstants extends TranslationBundle {
- public static CapabilityConstants get() {
- return NLS.getBundleFor(CapabilityConstants.class);
- }
-
- public String accessDatabase;
- public String administrateServer;
- public String batchChangesLimit;
- public String createAccount;
- public String createGroup;
- public String deleteGroup;
- public String createProject;
- public String emailReviewers;
- public String flushCaches;
- public String killTask;
- public String maintainServer;
- public String modifyAccount;
- public String priority;
- public String readAs;
- public String queryLimit;
- public String runAs;
- public String runGC;
- public String streamEvents;
- public String viewAccess;
- public String viewAllAccounts;
- public String viewCaches;
- public String viewConnections;
- public String viewPlugins;
- public String viewQueue;
- public String viewSecondaryEmails;
+public class CapabilityConstants {
+ public static String accessDatabase = "Access Database";
+ public static String administrateServer = "Administrate Server";
+ public static String batchChangesLimit = "Batch Changes Limit";
+ public static String createAccount = "Create Account";
+ public static String createGroup = "Create Group";
+ public static String deleteGroup = "Delete Group";
+ public static String createProject = "Create Project";
+ public static String emailReviewers = "Email Reviewers";
+ public static String flushCaches = "Flush Caches";
+ public static String killTask = "Kill Task";
+ public static String maintainServer = "Maintain Server";
+ public static String modifyAccount = "Modify Account";
+ public static String priority = "Priority";
+ public static String readAs = "Read As";
+ public static String queryLimit = "Query Limit";
+ public static String runAs = "Run As";
+ public static String runGC = "Run Garbage Collection";
+ public static String streamEvents = "Stream Events";
+ public static String viewAccess = "View Access";
+ public static String viewAllAccounts = "View All Accounts";
+ public static String viewCaches = "View Caches";
+ public static String viewConnections = "View Connections";
+ public static String viewPlugins = "View Plugins";
+ public static String viewQueue = "View Queue";
+ public static String viewSecondaryEmails = "View Secondary Emails";
}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 3d480d9..d5fa605 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -145,6 +145,7 @@
import com.google.gerrit.server.git.ReceivePackInitializer;
import com.google.gerrit.server.git.TagCache;
import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.receive.PushOptionsValidator;
import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
import com.google.gerrit.server.git.validators.CommentCountValidator;
import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
@@ -440,6 +441,7 @@
DynamicSet.setOf(binder(), ExternalIncludedIn.class);
DynamicSet.setOf(binder(), FilterIncludedIn.class);
DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
+ DynamicSet.setOf(binder(), PushOptionsValidator.class);
DynamicSet.setOf(binder(), PluginPushOption.class);
DynamicSet.setOf(binder(), PatchSetWebLink.class);
DynamicSet.setOf(binder(), ResolveConflictsWebLink.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index cd91745..eb81ee1 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -60,4 +60,12 @@
/** Whether we allow fix suggestions in HumanComments. */
public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
"GerritBackendFeature__allow_fix_suggestions_in_comments";
+
+ /**
+ * Whether the submit operation that is executed to auto-merge a change on push (see
+ * https://gerrit-review.googlesource.com/Documentation/user-upload.html#auto_merge) should use a
+ * {@code RefUpdateContext} with type {@code DIRECT_PUSH}.
+ */
+ public static String GERRIT_BACKEND_FEATURE_USE_DIRECT_PUSH_CONTEXT_FOR_SUBMIT_ON_PUSH =
+ "GerritBackendFeature__use_direct_push_context_for_submit_on_push";
}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 30f2ee8..cb5dc9d 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -288,7 +288,7 @@
if (message == null) {
message =
MessageFormat.format(
- ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
+ ChangeMessages.revertChangeDefaultMessage, subject, patch.commitId().name());
}
if (generatedChangeId != null) {
message = ChangeIdUtil.insertId(message, generatedChangeId, true);
diff --git a/java/com/google/gerrit/server/git/receive/PushOptionsValidator.java b/java/com/google/gerrit/server/git/receive/PushOptionsValidator.java
new file mode 100644
index 0000000..59caab5
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/PushOptionsValidator.java
@@ -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.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+
+/**
+ * Extension point to validate push options.
+ *
+ * <p>Possible usages are:
+ *
+ * <ul>
+ * <li>Reject pushes that use a certain push option or a certain combination of push options.
+ * <li>Print a warning if a certain push option is used (e.g because the push option is
+ * deprecated).
+ * </ul>
+ */
+@ExtensionPoint
+public interface PushOptionsValidator {
+ /**
+ * Validate the push options that have been specified for a push to the given ref.
+ *
+ * @param projectName The name of the project to which the user is pushing.
+ * @param refName The target ref of the push.
+ * @param pushOptions The options that have been specified on push. This map includes the Git push
+ * options (specified by {@code -o <key>=<value>}) and options which have been specified as
+ * part of the target ref name (specified as {@code refs/for/master%<key>=<value>}).
+ * @return A list of validation messages. If any message with type {@link
+ * com.google.gerrit.server.git.validators.ValidationMessage.Type#ERROR} or {@link
+ * com.google.gerrit.server.git.validators.ValidationMessage.Type#FATAL} is returned the push
+ * is rejected. Validation messages with other type are returned to the client, but let the
+ * push proceed.
+ */
+ ImmutableList<ValidationMessage> validate(
+ Project.NameKey projectName, String refName, ListMultimap<String, String> pushOptions);
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index cef4497..86985f3 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -142,6 +142,8 @@
import com.google.gerrit.server.config.ProjectConfigEntry;
import com.google.gerrit.server.edit.ChangeEdit;
import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
import com.google.gerrit.server.git.BanCommit;
import com.google.gerrit.server.git.ChangeReportFormatter;
import com.google.gerrit.server.git.GroupCollector;
@@ -285,6 +287,8 @@
public static final String DIRECT_PUSH_JUSTIFICATION_OPTION = "push-justification";
+ private static final String CUSTOM_KEYED_VALUE_OPTION = "custom-keyed-value";
+
interface Factory {
ReceiveCommits create(
ProjectState projectState,
@@ -413,7 +417,9 @@
private final CreateRefControl createRefControl;
private final DeadlineChecker.Factory deadlineCheckerFactory;
private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
+ private final ExperimentFeatures experimentFeatures;
private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+ private final DynamicSet<PushOptionsValidator> pushOptionsValidators;
private final DynamicSet<PluginPushOption> pluginPushOptions;
private final PluginSetContext<ReceivePackInitializer> initializers;
private final MergedByPushOp.Factory mergedByPushOpFactory;
@@ -479,6 +485,7 @@
private boolean setChangeAsPrivate;
private Optional<NoteDbPushOption> noteDbPushOption;
private Optional<String> tracePushOption = Optional.empty();
+ private Map<String, String> customKeyedValues = new HashMap<>();
private final TraceIdConsumer traceIdConsumer;
private MessageSender messageSender;
@@ -513,7 +520,9 @@
CreateRefControl createRefControl,
DeadlineChecker.Factory deadlineCheckerFactory,
DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory,
+ ExperimentFeatures experimentFeatures,
DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+ DynamicSet<PushOptionsValidator> pushOptionsValidators,
DynamicSet<PluginPushOption> pluginPushOptions,
PluginSetContext<ReceivePackInitializer> initializers,
PluginSetContext<CommentValidator> commentValidators,
@@ -570,6 +579,7 @@
this.createGroupPermissionSyncer = createGroupPermissionSyncer;
this.deadlineCheckerFactory = deadlineCheckerFactory;
this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
+ this.experimentFeatures = experimentFeatures;
this.editUtil = editUtil;
this.exceptionHooks = exceptionHooks;
this.hashtagsFactory = hashtagsFactory;
@@ -586,6 +596,7 @@
this.patchSetInfoFactory = patchSetInfoFactory;
this.permissionBackend = permissionBackend;
this.pluginConfigEntries = pluginConfigEntries;
+ this.pushOptionsValidators = pushOptionsValidators;
this.pluginPushOptions = pluginPushOptions;
this.projectCache = projectCache;
this.psUtil = psUtil;
@@ -1169,11 +1180,11 @@
ObjectInserter ins,
ImmutableList<CreateRequest> newChanges,
Task replaceProgress) {
+ ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
try (TraceTimer traceTimer =
newTimer(
"insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
- ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
logger.atWarning().log(
"Skipping change updates on %s because ref update failed: %s %s",
@@ -1217,8 +1228,26 @@
} catch (RestApiException | IOException | UpdateException e) {
throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
}
+ }
+ }
- if (magicBranch != null && magicBranch.submit) {
+ if (magicBranch != null && magicBranch.submit) {
+ // Using the submit option submits the created change(s) immediately without checking labels
+ // nor submit rules. Since code review is bypassed, same as on direct push, use a direct push
+ // RefUpdateContext to do the direct submit.
+ Optional<String> justification =
+ pushOptions.get(DIRECT_PUSH_JUSTIFICATION_OPTION).stream().findFirst();
+ try (RefUpdateContext ctx =
+ experimentFeatures.isFeatureEnabled(
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_FEATURE_USE_DIRECT_PUSH_CONTEXT_FOR_SUBMIT_ON_PUSH,
+ project.getNameKey())
+ ? RefUpdateContext.openDirectPush(justification)
+ : RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (TraceTimer traceTimer =
+ newTimer(
+ "insertChangesAndPatchSets#submit",
+ Metadata.builder().resourceCount(newChanges.size()))) {
try {
submit(newChanges, replaceByChange.values());
} catch (ResourceConflictException e) {
@@ -1866,6 +1895,7 @@
Map<String, Short> labels = new HashMap<>();
String message;
List<RevCommit> baseCommit;
+ ListMultimap<String, String> optionMap;
CmdLineParser cmdLineParser;
Set<String> hashtags = new HashSet<>();
@@ -1873,6 +1903,12 @@
String trace;
@Option(
+ name = "--push-justification",
+ metaVar = "NAME",
+ usage = "justification for the push if the 'submit' option is used to submit on push")
+ String pushJustification;
+
+ @Option(
name = "--deadline",
metaVar = "NAME",
usage = "deadline after which the push should be aborted")
@@ -1881,6 +1917,16 @@
@Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
List<ObjectId> base;
+ // Custom keyed values need to be specified as a push option ('-o
+ // custom-keyed-value=<key>:<value'). Setting the 'custom-keyed-value' option in the refname is
+ // not allowed. This option here is only defined, so that the 'custom-keyed-value' option gets
+ // mentioned in the help output.
+ @Option(
+ name = "--custom-keyed-value",
+ metaVar = "CUSTOM_KEYED_VALUES",
+ usage = "custom keyed value in the format '<key>:<value>'")
+ List<String> customKeyedValues;
+
@Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
String topic;
@@ -2099,7 +2145,7 @@
String parse(ListMultimap<String, String> pushOptions) throws CmdLineException {
String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
- ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
+ optionMap = LinkedListMultimap.create();
// Process and lop off the "%OPTION" suffix.
int optionStart = ref.indexOf('%');
@@ -2107,16 +2153,33 @@
for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
int e = s.indexOf('=');
if (0 < e) {
- options.put(s.substring(0, e), s.substring(e + 1));
+ optionMap.put(s.substring(0, e), s.substring(e + 1));
} else {
- options.put(s, "");
+ optionMap.put(s, "");
}
}
ref = ref.substring(0, optionStart);
}
- if (!options.isEmpty()) {
- cmdLineParser.parseOptionMap(options);
+ // We cannot accept the 'custom-key-value' option in the refname (deprecated format), since
+ // key and value are separated by ':' which is a character that is not allowed in the refname.
+ // If a user tries to specify '%custom-key-value=<key>:<value>' in the refname the push fails
+ // due to ':' not being allowed in refnames. In this case the push fails much earlier and we
+ // do not reach here. However if someone tries to specify the '%custom-key-value' option with
+ // allowed characters in the value, we return a message here telling the user that they should
+ // use the push option instead.
+ if (optionMap.containsKey(CUSTOM_KEYED_VALUE_OPTION)) {
+ throw cmdLineParser.reject(
+ String.format(
+ "option '%s' cannot be specified as an option in the ref name, use a push option"
+ + " instead ('-o %s=<key>:<value>')",
+ CUSTOM_KEYED_VALUE_OPTION, CUSTOM_KEYED_VALUE_OPTION));
+ }
+
+ optionMap.putAll(pushOptions);
+
+ if (!optionMap.isEmpty()) {
+ cmdLineParser.parseOptionMap(optionMap);
}
return ref;
}
@@ -2173,6 +2236,22 @@
String ref;
magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
+ // Parse custom keyed values.
+ for (String keyValue : pushOptions.get(CUSTOM_KEYED_VALUE_OPTION)) {
+ List<String> keyValueList = Splitter.on(":").trimResults().splitToList(keyValue);
+ if (keyValueList.size() != 2) {
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_OPTION,
+ String.format(
+ "the value for option '%s' must be given as '<key>:<format>'",
+ CUSTOM_KEYED_VALUE_OPTION)));
+ return;
+ }
+ customKeyedValues.put(keyValueList.get(0), keyValueList.get(1));
+ }
+
// Filter out plugin push options, as the parser would reject them as unknown.
ImmutableListMultimap<String, String> pushOptionsToParse =
pushOptions.entries().stream()
@@ -2189,6 +2268,16 @@
ref = null; // never happens
}
+ if (magicBranch.pushJustification != null && !magicBranch.submit) {
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_OPTION,
+ "when pushing for a review a push justification can only be set when the"
+ + " 'submit' option is used"));
+ return;
+ }
+
if (magicBranch.skipValidation) {
reject(
cmd,
@@ -2404,6 +2493,8 @@
}
}
}
+
+ validatePushOptions(cmd, projectState.getNameKey(), ref, magicBranch.optionMap);
} catch (IOException e) {
throw new StorageException(
String.format("Error walking to %s in project %s", destBranch, project.getName()), e);
@@ -2416,6 +2507,28 @@
}
}
+ private void validatePushOptions(
+ ReceiveCommand cmd,
+ Project.NameKey projectName,
+ String refName,
+ ListMultimap<String, String> pushOptions) {
+ ImmutableList<ValidationMessage> validationMessages =
+ StreamSupport.stream(pushOptionsValidators.entries().spliterator(), /* parallel= */ false)
+ .flatMap(
+ pluginPushValidators ->
+ pluginPushValidators.get().validate(projectName, refName, pushOptions).stream())
+ .collect(toImmutableList());
+ for (ValidationMessage validationMessage : validationMessages) {
+ if (validationMessage.isError()) {
+ reject(
+ cmd,
+ RejectionReason.create(MetricBucket.INVALID_OPTION, validationMessage.getMessage()));
+ } else {
+ messages.add(validationMessage);
+ }
+ }
+ }
+
private boolean isPluginPushOption(String pushOptionName) {
if (transitionalPluginOptions.contains(pushOptionName)) {
return true;
@@ -3030,6 +3143,7 @@
.create(changeId, commit, refName)
.setTopic(magicBranch.topic)
.setPrivate(setChangeAsPrivate)
+ .setCustomKeyedValues(ImmutableMap.copyOf(customKeyedValues))
.setWorkInProgress(magicBranch.shouldSetWorkInProgressOnNewChanges())
// The commit has already been validated in
// selectNewAndReplacedChangesFromMagicBranch.
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index 5c9106c..484a064 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -569,24 +569,18 @@
return this.authors = authors;
}
- @Override
- public void populateEmailContent() throws EmailException {
- BranchEmailUtils.addBranchData(email, args, branch);
- setThreadHeaders();
-
- email.addSoyParam("changeId", change.getKey().get());
- email.addSoyParam("coverLetter", getCoverLetter());
- email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
- email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
- email.addSoyParam("diffLines", ChangeEmail.getDiffTemplateData(getUnifiedDiff()));
-
+ protected void addChangeRelatedSoyParams() {
+ // diffLines is the structured diff format for HTML emails.
+ // unifiedDiff is textual diff for plaintext emails.
+ email.addSoyParam("diffLines", ChangeEmail.getDiffTemplateData(getUnifiedDiff().trim()));
email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
- email.addSoyEmailDataParam("changeDetail", getChangeDetail());
- email.addSoyEmailDataParam("changeUrl", getChangeUrl());
email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
- Map<String, String> changeData = new HashMap<>();
+ // changeDetail - is a plaintext summary of the change (message, files, delta)
+ email.addSoyEmailDataParam("changeDetail", getChangeDetail());
+ email.addSoyEmailDataParam("changeUrl", getChangeUrl());
+ Map<String, String> changeData = new HashMap<>();
String subject = change.getSubject();
String originalSubject = change.getOriginalSubject();
changeData.put("subject", subject);
@@ -611,7 +605,13 @@
patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
email.addSoyParam("patchSetInfo", patchSetInfoData);
+ // We need names rather than account ids / emails to make it user readable.
+ email.addSoyParam(
+ "attentionSet",
+ currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
+ }
+ protected void addEmailFooters() {
email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
@@ -625,13 +625,24 @@
for (Account.Id attentionUser : currentAttentionSet) {
email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
}
- // We need names rather than account ids / emails to make it user readable.
- email.addSoyParam(
- "attentionSet",
- currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
+ }
+ @Override
+ public void populateEmailContent() throws EmailException {
+ BranchEmailUtils.addBranchData(email, args, branch);
+ setThreadHeaders();
+
+ email.addSoyParam("coverLetter", getCoverLetter());
+ email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+ email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
+
+ addChangeRelatedSoyParams();
+ addEmailFooters();
+
+ // Set email subject.
setChangeSubjectHeader();
+ // Set email content
if (email.useHtml()) {
email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
}
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 6a09f0f2..e0fc648 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -197,24 +197,34 @@
* Git and not get deleted. These refs point to an empty tree. We delete such refs.
*/
@Override
- protected void deleteEmptyDraftsByKey(Collection<Ref> refs) throws IOException {
+ protected void deleteEmptyDraftsByKey(Collection<Ref> refs) {
long zombieRefsCnt = refs.size();
long deletedRefsCnt = 0;
long startTime = System.currentTimeMillis();
for (List<Ref> refsBatch : Iterables.partition(refs, CHUNK_SIZE)) {
- deleteZombieDraftsBatch(refsBatch);
- long elapsed = (System.currentTimeMillis() - startTime) / 1000;
- deletedRefsCnt += refsBatch.size();
- logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+ try {
+ deleteZombieDraftsBatch(refsBatch);
+ long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+ deletedRefsCnt += refsBatch.size();
+ logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+ } catch (Exception error) {
+ logger.atWarning().withCause(error).log("Failed to delete drafts as a batch");
+ }
}
}
@Override
- protected void deleteZombieDrafts(ListMultimap<Ref, HumanComment> drafts) throws IOException {
+ protected void deleteZombieDrafts(ListMultimap<Ref, HumanComment> drafts) {
for (Map.Entry<Ref, Collection<HumanComment>> e : drafts.asMap().entrySet()) {
- deleteZombieDraftsForChange(
- getAccountId(e.getKey()), getChangeNotes(getChangeId(e.getKey())), e.getValue());
+ try {
+ deleteZombieDraftsForChange(
+ getAccountId(e.getKey()), getChangeNotes(getChangeId(e.getKey())), e.getValue());
+ } catch (Exception error) {
+ logger.atWarning().withCause(error).log(
+ "Failed to delete draft for change %s account %s",
+ getChangeId(e.getKey()), getAccountId(e.getKey()));
+ }
}
}
diff --git a/java/com/google/gerrit/server/permissions/AbstractChangeControl.java b/java/com/google/gerrit/server/permissions/AbstractChangeControl.java
new file mode 100644
index 0000000..863f6a6
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractChangeControl.java
@@ -0,0 +1,299 @@
+// 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.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
+
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Abstract access control management for a user accessing a single change.
+ *
+ * <p>Contains all logic that is common for checking permissions on an existing change and on a
+ * change that is not created yet.
+ */
+abstract class AbstractChangeControl {
+ protected final ProjectControl projectControl;
+ protected final RefControl refControl;
+ protected final boolean isNew;
+
+ /** Is this user the owner of the change? */
+ protected final boolean isOwner;
+
+ private Map<String, PermissionRange> labels;
+
+ AbstractChangeControl(
+ ProjectControl projectControl, RefControl refControl, boolean isNew, boolean isOwner) {
+ this.projectControl = projectControl;
+ this.refControl = refControl;
+ this.isNew = isNew;
+ this.isOwner = isOwner;
+ }
+
+ protected abstract ForChange asForChange();
+
+ protected CurrentUser getUser() {
+ return refControl.getUser();
+ }
+
+ protected boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
+ if (perm instanceof ChangePermission) {
+ return can((ChangePermission) perm);
+ } else if (perm instanceof AbstractLabelPermission) {
+ return can((AbstractLabelPermission) perm);
+ } else if (perm instanceof AbstractLabelPermission.WithValue) {
+ return can((AbstractLabelPermission.WithValue) perm);
+ }
+ throw new PermissionBackendException(perm + " unsupported");
+ }
+
+ protected boolean can(ChangePermission perm) throws PermissionBackendException {
+ try {
+ return switch (perm) {
+ case READ -> isVisible();
+ case ABANDON -> canAbandon();
+ case DELETE -> projectControl.isAdmin() || refControl.canDeleteChanges(isOwner);
+ case ADD_PATCH_SET -> canAddPatchSet();
+ case EDIT_DESCRIPTION -> canEditDescription();
+ case EDIT_HASHTAGS -> canEditHashtags();
+ case EDIT_CUSTOM_KEYED_VALUES -> canEditCustomKeyedValues();
+ case EDIT_TOPIC_NAME -> canEditTopicName();
+ case REBASE -> canRebase();
+ case REBASE_ON_BEHALF_OF_UPLOADER -> canRebaseOnBehalfOfUploader();
+ case RESTORE -> canRestore();
+ case REVERT -> canRevert();
+ case SUBMIT -> refControl.canSubmit(isOwner);
+ case TOGGLE_WORK_IN_PROGRESS_STATE -> canToggleWorkInProgressState();
+ case REMOVE_REVIEWER, SUBMIT_AS -> refControl.canPerform(changePermissionName(perm));
+ };
+ } catch (StorageException e) {
+ throw new PermissionBackendException("unavailable", e);
+ }
+ }
+
+ /** Can this user see this change? */
+ protected boolean isVisible() {
+ // Does the user have READ permission on the destination?
+ return refControl.asForRef().testOrFalse(RefPermission.READ);
+ }
+
+ /** Can this user abandon this change? */
+ private boolean canAbandon() {
+ return isOwner // owner (aka creator) of the change can abandon
+ || refControl.isOwner() // branch owner can abandon
+ || projectControl.isOwner() // project owner can abandon
+ || refControl.canPerform(Permission.ABANDON) // user can abandon a specific ref
+ || projectControl.isAdmin();
+ }
+
+ /** Can this user add a patch set to this change? */
+ private boolean canAddPatchSet() {
+ if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)) {
+ return false;
+ }
+ if (isOwner) {
+ return true;
+ }
+ return refControl.canAddPatchSet();
+ }
+
+ /** Can this user edit the topic name? */
+ private boolean canEditTopicName() {
+ if (isNew) {
+ return isOwner // owner (aka creator) of the change can edit topic
+ || refControl.isOwner() // branch owner can edit topic
+ || projectControl.isOwner() // project owner can edit topic
+ || refControl.canPerform(
+ Permission.EDIT_TOPIC_NAME) // user can edit topic on a specific ref
+ || projectControl.isAdmin();
+ }
+ return refControl.canForceEditTopicName(isOwner);
+ }
+
+ /** Can this user edit the description? */
+ private boolean canEditDescription() {
+ if (isNew) {
+ return isOwner // owner (aka creator) of the change can edit desc
+ || refControl.isOwner() // branch owner can edit desc
+ || projectControl.isOwner() // project owner can edit desc
+ || projectControl.isAdmin();
+ }
+ return false;
+ }
+
+ /** Can this user edit the hashtag name? */
+ private boolean canEditHashtags() {
+ return isOwner // owner (aka creator) of the change can edit hashtags
+ || refControl.isOwner() // branch owner can edit hashtags
+ || projectControl.isOwner() // project owner can edit hashtags
+ || refControl.canPerform(
+ Permission.EDIT_HASHTAGS) // user can edit hashtag on a specific ref
+ || projectControl.isAdmin();
+ }
+
+ /** Can this user edit the custom keyed values? */
+ private boolean canEditCustomKeyedValues() {
+ return isOwner // owner (aka creator) of the change can edit custom keyed values
+ || projectControl.isAdmin();
+ }
+
+ /** Can this user rebase this change? */
+ private boolean canRebase() {
+ return (isOwner || refControl.canSubmit(isOwner) || refControl.canRebase())
+ && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+ }
+
+ /**
+ * Can this user rebase this change on behalf of the uploader?
+ *
+ * <p>This only checks the permissions of the rebaser (aka the impersonating user).
+ *
+ * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+ * user) to have permissions to create the new patch set. These permissions need to be checked
+ * separately.
+ */
+ private boolean canRebaseOnBehalfOfUploader() {
+ return (isOwner || refControl.canSubmit(isOwner) || refControl.canRebase());
+ }
+
+ /** Can this user restore this change? */
+ private boolean canRestore() {
+ // Anyone who can abandon the change can restore it, as long as they can create changes.
+ return canAbandon() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+ }
+
+ /** Can this user revert this change? */
+ private boolean canRevert() {
+ return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+ }
+
+ /** Can this user toggle WorkInProgress state? */
+ private boolean canToggleWorkInProgressState() {
+ return isOwner
+ || projectControl.isOwner()
+ || refControl.canPerform(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
+ || projectControl.isAdmin();
+ }
+
+ private boolean can(AbstractLabelPermission perm) {
+ return !label(labelPermissionName(perm)).isEmpty();
+ }
+
+ private boolean can(AbstractLabelPermission.WithValue perm) {
+ PermissionRange r = label(labelPermissionName(perm));
+ if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
+ return false;
+ }
+ return r.contains(perm.value());
+ }
+
+ private PermissionRange label(String permission) {
+ if (labels == null) {
+ labels = Maps.newHashMapWithExpectedSize(4);
+ }
+ PermissionRange r = labels.get(permission);
+ if (r == null) {
+ r = getRange(permission);
+ labels.put(permission, r);
+ }
+ return r;
+ }
+
+ /** The range of permitted values associated with a label permission. */
+ private PermissionRange getRange(String permission) {
+ return refControl.getRange(permission, isOwner);
+ }
+
+ protected class ForChangeImpl extends ForChange {
+ @Nullable private final Change.Id changeId;
+
+ private String resourcePath;
+
+ protected ForChangeImpl(@Nullable Change.Id changeId) {
+ this.changeId = changeId;
+ }
+
+ @Override
+ public String resourcePath() {
+ if (resourcePath == null) {
+ resourcePath =
+ String.format(
+ "/projects/%s/+changes/%s",
+ projectControl.getProjectState().getName(), changeId != null ? changeId.get() : 0);
+ }
+ return resourcePath;
+ }
+
+ @Override
+ public void check(ChangePermissionOrLabel perm)
+ throws AuthException, PermissionBackendException {
+ if (!can(perm)) {
+ throw new AuthException(
+ perm.describeForException()
+ + " not permitted"
+ + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
+ }
+ }
+
+ @Override
+ public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+ throws PermissionBackendException {
+ Set<T> ok = newSet(permSet);
+ for (T perm : permSet) {
+ if (can(perm)) {
+ ok.add(perm);
+ }
+ }
+ return ok;
+ }
+
+ @Override
+ public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+ return new PermissionBackendCondition.ForChange(this, perm, getUser());
+ }
+ }
+
+ protected static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
+ if (permSet instanceof EnumSet) {
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ Set<T> s = ((EnumSet) permSet).clone();
+ s.clear();
+ return s;
+ }
+ return Sets.newHashSetWithExpectedSize(permSet.size());
+ }
+
+ protected static String changePermissionName(ChangePermission changePermission) {
+ // Within this class, it's programmer error to call this method on a
+ // ChangePermission that isn't associated with a permission name.
+ return DefaultPermissionMappings.changePermissionName(changePermission)
+ .orElseThrow(() -> new IllegalStateException("no name for " + changePermission));
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
index e4a8160..ba7caed 100644
--- a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -21,8 +21,7 @@
import com.google.gerrit.server.util.LabelVote;
/** Abstract permission representing a label. */
-public abstract class AbstractLabelPermission
- implements ChangePermissionOrLabel, RefPermissionOrLabel {
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
public enum ForUser {
SELF,
ON_BEHALF_OF
@@ -91,7 +90,7 @@
}
/** A {@link AbstractLabelPermission} at a specific value. */
- public abstract static class WithValue implements ChangePermissionOrLabel, RefPermissionOrLabel {
+ public abstract static class WithValue implements ChangePermissionOrLabel {
private final ForUser forUser;
private final LabelVote label;
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 39a6310..3fe4811 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,32 +14,18 @@
package com.google.gerrit.server.permissions;
-import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-
import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.PermissionRange;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.conditions.BooleanCondition;
-import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.assistedinject.Assisted;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.Map;
-import java.util.Set;
import javax.inject.Inject;
/** Access control management for a user accessing a single change. */
-public class ChangeControl {
+public class ChangeControl extends AbstractChangeControl {
public interface Factory {
ChangeControl create(
ProjectControl projectControl, RefControl refControl, ChangeData changeData);
@@ -47,8 +33,6 @@
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private final ProjectControl projectControl;
- private final RefControl refControl;
private final ChangeData changeData;
@Inject
@@ -56,155 +40,31 @@
@Assisted ProjectControl projectControl,
@Assisted RefControl refControl,
@Assisted ChangeData changeData) {
- this.projectControl = projectControl;
- this.refControl = refControl;
+ super(projectControl, refControl, changeData.change().isNew(), isOwner(refControl, changeData));
this.changeData = changeData;
}
- ForChange asForChange() {
- return new ForChangeImpl();
+ private static boolean isOwner(RefControl refControl, ChangeData changeData) {
+ CurrentUser user = refControl.getUser();
+ if (user.isIdentifiedUser()) {
+ Account.Id id = user.asIdentifiedUser().getAccountId();
+ return id.equals(changeData.change().getOwner());
+ }
+ return false;
}
- private CurrentUser getUser() {
- return refControl.getUser();
- }
-
- private ProjectControl getProjectControl() {
- return refControl.getProjectControl();
- }
-
- private Change getChange() {
- return changeData.change();
+ @Override
+ public ForChange asForChange() {
+ return new ForChangeImpl(changeData.getId());
}
/** Can this user see this change? */
- boolean isVisible() {
+ @Override
+ protected boolean isVisible() {
if (changeData.isPrivateOrThrow() && !isPrivateVisible(changeData)) {
return false;
}
- // Does the user have READ permission on the destination?
- return refControl.asForRef().testOrFalse(RefPermission.READ);
- }
-
- /** Can this user abandon this change? */
- private boolean canAbandon() {
- return isOwner() // owner (aka creator) of the change can abandon
- || refControl.isOwner() // branch owner can abandon
- || getProjectControl().isOwner() // project owner can abandon
- || refControl.canPerform(Permission.ABANDON) // user can abandon a specific ref
- || getProjectControl().isAdmin();
- }
-
- /** Can this user rebase this change? */
- private boolean canRebase() {
- return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase())
- && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
- }
-
- /**
- * Can this user rebase this change on behalf of the uploader?
- *
- * <p>This only checks the permissions of the rebaser (aka the impersonating user).
- *
- * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
- * user) to have permissions to create the new patch set. These permissions need to be checked
- * separately.
- */
- private boolean canRebaseOnBehalfOfUploader() {
- return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase());
- }
-
- /** Can this user restore this change? */
- private boolean canRestore() {
- // Anyone who can abandon the change can restore it, as long as they can create changes.
- return canAbandon() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
- }
-
- /** Can this user revert this change? */
- private boolean canRevert() {
- return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
- }
-
- /** The range of permitted values associated with a label permission. */
- private PermissionRange getRange(String permission) {
- return refControl.getRange(permission, isOwner());
- }
-
- /** Can this user add a patch set to this change? */
- private boolean canAddPatchSet() {
- if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)) {
- return false;
- }
- if (isOwner()) {
- return true;
- }
- return refControl.canAddPatchSet();
- }
-
- /** Is this user the owner of the change? */
- private boolean isOwner() {
- if (getUser().isIdentifiedUser()) {
- Account.Id id = getUser().asIdentifiedUser().getAccountId();
- return id.equals(getChange().getOwner());
- }
- return false;
- }
-
- /** Is this user a reviewer for the change? */
- private boolean isReviewer(ChangeData cd) {
- if (getUser().isIdentifiedUser()) {
- ImmutableSet<Account.Id> results = cd.reviewers().all();
- return results.contains(getUser().getAccountId());
- }
- return false;
- }
-
- /** Can this user edit the topic name? */
- private boolean canEditTopicName() {
- if (getChange().isNew()) {
- return isOwner() // owner (aka creator) of the change can edit topic
- || refControl.isOwner() // branch owner can edit topic
- || getProjectControl().isOwner() // project owner can edit topic
- || refControl.canPerform(
- Permission.EDIT_TOPIC_NAME) // user can edit topic on a specific ref
- || getProjectControl().isAdmin();
- }
- return refControl.canForceEditTopicName(isOwner());
- }
-
- /** Can this user toggle WorkInProgress state? */
- private boolean canToggleWorkInProgressState() {
- return isOwner()
- || getProjectControl().isOwner()
- || refControl.canPerform(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
- || getProjectControl().isAdmin();
- }
-
- /** Can this user edit the description? */
- private boolean canEditDescription() {
- if (getChange().isNew()) {
- return isOwner() // owner (aka creator) of the change can edit desc
- || refControl.isOwner() // branch owner can edit desc
- || getProjectControl().isOwner() // project owner can edit desc
- || getProjectControl().isAdmin();
- }
- return false;
- }
-
- /** Can this user edit the hashtag name? */
- private boolean canEditHashtags() {
- return isOwner() // owner (aka creator) of the change can edit hashtags
- || refControl.isOwner() // branch owner can edit hashtags
- || getProjectControl().isOwner() // project owner can edit hashtags
- || refControl.canPerform(
- Permission.EDIT_HASHTAGS) // user can edit hashtag on a specific ref
- || getProjectControl().isAdmin();
- }
-
- /** Can this user edit the custom keyed values? */
- private boolean canEditCustomKeyedValues() {
- return isOwner() // owner (aka creator) of the change can edit custom keyed values
- || getProjectControl().isAdmin();
+ return super.isVisible();
}
private boolean isPrivateVisible(ChangeData cd) {
@@ -215,7 +75,7 @@
return true;
}
- if (isOwner()) {
+ if (isOwner) {
logger.atFine().log(
"%s can see private change %s because this user is the change owner",
getUser().getLoggableName(), cd.getId());
@@ -247,125 +107,12 @@
return false;
}
- private class ForChangeImpl extends ForChange {
- private Map<String, PermissionRange> labels;
- private String resourcePath;
-
- private ForChangeImpl() {}
-
- @Override
- public String resourcePath() {
- if (resourcePath == null) {
- resourcePath =
- String.format(
- "/projects/%s/+changes/%s",
- getProjectControl().getProjectState().getName(), changeData.getId().get());
- }
- return resourcePath;
+ /** Is this user a reviewer for the change? */
+ private boolean isReviewer(ChangeData cd) {
+ if (getUser().isIdentifiedUser()) {
+ ImmutableSet<Account.Id> results = cd.reviewers().all();
+ return results.contains(getUser().getAccountId());
}
-
- @Override
- public void check(ChangePermissionOrLabel perm)
- throws AuthException, PermissionBackendException {
- if (!can(perm)) {
- throw new AuthException(
- perm.describeForException()
- + " not permitted"
- + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
- }
- }
-
- @Override
- public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
- throws PermissionBackendException {
- Set<T> ok = newSet(permSet);
- for (T perm : permSet) {
- if (can(perm)) {
- ok.add(perm);
- }
- }
- return ok;
- }
-
- @Override
- public BooleanCondition testCond(ChangePermissionOrLabel perm) {
- return new PermissionBackendCondition.ForChange(this, perm, getUser());
- }
-
- private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
- if (perm instanceof ChangePermission) {
- return can((ChangePermission) perm);
- } else if (perm instanceof AbstractLabelPermission) {
- return can((AbstractLabelPermission) perm);
- } else if (perm instanceof AbstractLabelPermission.WithValue) {
- return can((AbstractLabelPermission.WithValue) perm);
- }
- throw new PermissionBackendException(perm + " unsupported");
- }
-
- private boolean can(ChangePermission perm) throws PermissionBackendException {
- try {
- return switch (perm) {
- case READ -> isVisible();
- case ABANDON -> canAbandon();
- case DELETE -> getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
- case ADD_PATCH_SET -> canAddPatchSet();
- case EDIT_DESCRIPTION -> canEditDescription();
- case EDIT_HASHTAGS -> canEditHashtags();
- case EDIT_CUSTOM_KEYED_VALUES -> canEditCustomKeyedValues();
- case EDIT_TOPIC_NAME -> canEditTopicName();
- case REBASE -> canRebase();
- case REBASE_ON_BEHALF_OF_UPLOADER -> canRebaseOnBehalfOfUploader();
- case RESTORE -> canRestore();
- case REVERT -> canRevert();
- case SUBMIT -> refControl.canSubmit(isOwner());
- case TOGGLE_WORK_IN_PROGRESS_STATE -> canToggleWorkInProgressState();
- case REMOVE_REVIEWER, SUBMIT_AS -> refControl.canPerform(changePermissionName(perm));
- };
- } catch (StorageException e) {
- throw new PermissionBackendException("unavailable", e);
- }
- }
-
- private boolean can(AbstractLabelPermission perm) {
- return !label(labelPermissionName(perm)).isEmpty();
- }
-
- private boolean can(AbstractLabelPermission.WithValue perm) {
- PermissionRange r = label(labelPermissionName(perm));
- if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
- return false;
- }
- return r.contains(perm.value());
- }
-
- private PermissionRange label(String permission) {
- if (labels == null) {
- labels = Maps.newHashMapWithExpectedSize(4);
- }
- PermissionRange r = labels.get(permission);
- if (r == null) {
- r = getRange(permission);
- labels.put(permission, r);
- }
- return r;
- }
- }
-
- private static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
- if (permSet instanceof EnumSet) {
- @SuppressWarnings({"unchecked", "rawtypes"})
- Set<T> s = ((EnumSet) permSet).clone();
- s.clear();
- return s;
- }
- return Sets.newHashSetWithExpectedSize(permSet.size());
- }
-
- private static String changePermissionName(ChangePermission changePermission) {
- // Within this class, it's programmer error to call this method on a
- // ChangePermission that isn't associated with a permission name.
- return DefaultPermissionMappings.changePermissionName(changePermission)
- .orElseThrow(() -> new IllegalStateException("no name for " + changePermission));
+ return false;
}
}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControlForChangeToBeCreated.java b/java/com/google/gerrit/server/permissions/ChangeControlForChangeToBeCreated.java
new file mode 100644
index 0000000..b8418f4
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/ChangeControlForChangeToBeCreated.java
@@ -0,0 +1,31 @@
+// 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.permissions;
+
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+
+/** Access control management for a user accessing a change that has not been created yet. */
+public class ChangeControlForChangeToBeCreated extends AbstractChangeControl {
+
+ public ChangeControlForChangeToBeCreated(
+ ProjectControl projectControl, RefControl refControl, boolean isOwner) {
+ super(projectControl, refControl, /* isNew= */ true, isOwner);
+ }
+
+ @Override
+ public ForChange asForChange() {
+ return new ForChangeImpl(/* changeId= */ null);
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 6a8c92b..0fc9807 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -173,18 +173,23 @@
}
@Override
- public void check(RefPermissionOrLabel perm) throws PermissionBackendException {
+ public ForChange changeToBeCreated(boolean isChangeOwner) {
+ return new FailedChange(message, cause);
+ }
+
+ @Override
+ public void check(RefPermission perm) throws PermissionBackendException {
throw new PermissionBackendException(message, cause);
}
@Override
- public <T extends RefPermissionOrLabel> Set<T> test(Collection<T> permSet)
+ public Set<RefPermission> test(Collection<RefPermission> permSet)
throws PermissionBackendException {
throw new PermissionBackendException(message, cause);
}
@Override
- public BooleanCondition testCond(RefPermissionOrLabel perm) {
+ public BooleanCondition testCond(RefPermission perm) {
throw new UnsupportedOperationException(
"FailedPermissionBackend does not support conditions");
}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 55cdc3d..8e2461d 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -394,33 +394,24 @@
/** Returns an instance scoped to change. */
public abstract ForChange change(ChangeNotes notes);
+ /** Returns an instance scoped to change that has not been created yet. */
+ public abstract ForChange changeToBeCreated(boolean isOwner);
+
/**
* Verify scoped user can {@code perm}, throwing if denied.
*
* <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
* propagated. In business logic, where the exception would have to be caught, prefer using
- * {@link #test(RefPermissionOrLabel)}.
+ * {@link #test(RefPermission)}.
*/
- public abstract void check(RefPermissionOrLabel perm)
- throws AuthException, PermissionBackendException;
+ public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
/** Filter {@code permSet} to permissions scoped user might be able to perform. */
- public abstract <T extends RefPermissionOrLabel> Set<T> test(Collection<T> permSet)
+ public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
throws PermissionBackendException;
- public boolean test(RefPermissionOrLabel perm) throws PermissionBackendException {
- return test(Collections.singleton(perm)).contains(perm);
- }
-
- /**
- * Test which values of a label the user may be able to set.
- *
- * @param label definition of the label to test values of.
- * @return set containing values the user may be able to use; may be empty if none.
- * @throws PermissionBackendException if failure consulting backend configuration.
- */
- public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
- return test(valuesOf(requireNonNull(label, "LabelType")));
+ public boolean test(RefPermission perm) throws PermissionBackendException {
+ return test(EnumSet.of(perm)).contains(perm);
}
/**
@@ -433,7 +424,7 @@
* @return true if the user might be able to perform the permission; false if the user may be
* missing the necessary grants or state, or if the backend threw an exception.
*/
- public boolean testOrFalse(RefPermissionOrLabel perm) {
+ public boolean testOrFalse(RefPermission perm) {
try {
return test(perm);
} catch (PermissionBackendException e) {
@@ -442,13 +433,7 @@
}
}
- public abstract BooleanCondition testCond(RefPermissionOrLabel perm);
-
- private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
- return label.getValues().stream()
- .map(v -> new LabelPermission.WithValue(label, v))
- .collect(toSet());
- }
+ public abstract BooleanCondition testCond(RefPermission perm);
}
/** PermissionBackend scoped to a user, project, reference and change. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
index 7018fa0..a92e504 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
@@ -148,10 +148,10 @@
public static class ForRef extends PermissionBackendCondition {
private final PermissionBackend.ForRef impl;
- private final RefPermissionOrLabel perm;
+ private final RefPermission perm;
private final CurrentUser user;
- public ForRef(PermissionBackend.ForRef impl, RefPermissionOrLabel perm, CurrentUser user) {
+ public ForRef(PermissionBackend.ForRef impl, RefPermission perm, CurrentUser user) {
this.impl = impl;
this.perm = perm;
this.user = user;
@@ -161,7 +161,7 @@
return impl;
}
- public RefPermissionOrLabel permission() {
+ public RefPermission permission() {
return perm;
}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 194179e..c86f8f6 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -123,6 +123,11 @@
return changeControlFactory.create(this, controlForRef(cd.branchOrThrow()), cd);
}
+ ChangeControlForChangeToBeCreated controlForChangeToBeCreated(
+ RefControl refControl, boolean isOwner) {
+ return new ChangeControlForChangeToBeCreated(this, refControl, isOwner);
+ }
+
RefControl controlForRef(BranchNameKey ref) {
return controlForRef(ref.branch());
}
@@ -453,7 +458,7 @@
throw new PermissionBackendException(perm.describeForException() + " unsupported");
}
- private boolean can(ProjectPermission perm) throws PermissionBackendException {
+ private boolean can(ProjectPermission perm) {
return switch (perm) {
case ACCESS -> user.isInternalUser() || isOwner() || canPerformOnAnyRef(Permission.READ);
case READ -> allRefsAreVisible(Collections.emptySet());
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 88e53f7..5dd6be0 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -15,12 +15,8 @@
package com.google.gerrit.server.permissions;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
@@ -47,7 +43,6 @@
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
-import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.lib.Constants;
@@ -487,7 +482,6 @@
}
private class ForRefImpl extends ForRef {
- private Map<String, PermissionRange> labels;
private String resourcePath;
@Override
@@ -524,17 +518,14 @@
}
@Override
- public void check(RefPermissionOrLabel perm) throws AuthException, PermissionBackendException {
- if (perm instanceof RefPermission) {
- check((RefPermission) perm);
- } else {
- if (!can(perm)) {
- throw new AuthException(perm.describeForException() + " not permitted");
- }
- }
+ public ForChange changeToBeCreated(boolean isOwner) {
+ return getProjectControl()
+ .controlForChangeToBeCreated(RefControl.this, isOwner)
+ .asForChange();
}
- private void check(RefPermission perm) throws AuthException, PermissionBackendException {
+ @Override
+ public void check(RefPermission perm) throws AuthException, PermissionBackendException {
if (!can(perm)) {
PermissionDeniedException pde = new PermissionDeniedException(perm, refName);
switch (perm) {
@@ -601,10 +592,10 @@
}
@Override
- public <T extends RefPermissionOrLabel> Set<T> test(Collection<T> permSet)
+ public Set<RefPermission> test(Collection<RefPermission> permSet)
throws PermissionBackendException {
- Set<T> ok = newSet(permSet);
- for (T perm : permSet) {
+ EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
+ for (RefPermission perm : permSet) {
if (can(perm)) {
ok.add(perm);
}
@@ -613,44 +604,9 @@
}
@Override
- public BooleanCondition testCond(RefPermissionOrLabel perm) {
+ public BooleanCondition testCond(RefPermission perm) {
return new PermissionBackendCondition.ForRef(this, perm, getUser());
}
-
- private boolean can(RefPermissionOrLabel perm) throws PermissionBackendException {
- if (perm instanceof RefPermission) {
- return RefControl.this.can((RefPermission) perm);
- } else if (perm instanceof AbstractLabelPermission) {
- return can((AbstractLabelPermission) perm);
- } else if (perm instanceof AbstractLabelPermission.WithValue) {
- return can((AbstractLabelPermission.WithValue) perm);
- }
- throw new PermissionBackendException(perm + " unsupported");
- }
-
- private boolean can(AbstractLabelPermission perm) {
- return !label(labelPermissionName(perm)).isEmpty();
- }
-
- private boolean can(AbstractLabelPermission.WithValue perm) {
- PermissionRange r = label(labelPermissionName(perm));
- if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
- return false;
- }
- return r.contains(perm.value());
- }
-
- private PermissionRange label(String permission) {
- if (labels == null) {
- labels = Maps.newHashMapWithExpectedSize(4);
- }
- PermissionRange r = labels.get(permission);
- if (r == null) {
- r = getRange(permission);
- labels.put(permission, r);
- }
- return r;
- }
}
protected boolean can(RefPermission perm) throws PermissionBackendException {
@@ -773,15 +729,4 @@
return DefaultPermissionMappings.refPermissionName(refPermission)
.orElseThrow(() -> new IllegalStateException("no name for " + refPermission));
}
-
- // Same as in ChangeControl
- private static <T extends RefPermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
- if (permSet instanceof EnumSet) {
- @SuppressWarnings({"unchecked", "rawtypes"})
- Set<T> s = ((EnumSet) permSet).clone();
- s.clear();
- return s;
- }
- return Sets.newHashSetWithExpectedSize(permSet.size());
- }
}
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
index da5b745..34c46af 100644
--- a/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -18,7 +18,7 @@
import com.google.gerrit.extensions.api.access.GerritPermission;
-public enum RefPermission implements RefPermissionOrLabel {
+public enum RefPermission implements GerritPermission {
READ,
CREATE,
diff --git a/java/com/google/gerrit/server/permissions/RefPermissionOrLabel.java b/java/com/google/gerrit/server/permissions/RefPermissionOrLabel.java
deleted file mode 100644
index 3ec74ba..0000000
--- a/java/com/google/gerrit/server/permissions/RefPermissionOrLabel.java
+++ /dev/null
@@ -1,20 +0,0 @@
-// 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.permissions;
-
-import com.google.gerrit.extensions.api.access.GerritPermission;
-
-/** A {@link RefPermission} or a {@link AbstractLabelPermission}. */
-public interface RefPermissionOrLabel extends GerritPermission {}
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 9cd7908..77101ccb 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -392,21 +392,21 @@
if (initialMessage == null) {
initialMessage =
MessageFormat.format(
- ChangeMessages.get().revertSubmissionDefaultMessage,
+ ChangeMessages.revertSubmissionDefaultMessage,
changeNotes.getCurrentPatchSet().commitId().name());
}
// For performance purposes: Almost all cases will end here.
if (!subject.startsWith("Revert")) {
return MessageFormat.format(
- ChangeMessages.get().revertSubmissionUserMessage, subject, initialMessage);
+ ChangeMessages.revertSubmissionUserMessage, subject, initialMessage);
}
Matcher matcher = patternRevertSubjectWithNum.matcher(subject);
if (matcher.matches()) {
return MessageFormat.format(
- ChangeMessages.get().revertSubmissionOfRevertSubmissionUserMessage,
+ ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage,
Integer.valueOf(matcher.group(1)) + 1,
matcher.group(2),
changeNotes.getCurrentPatchSet().commitId().name());
@@ -415,14 +415,14 @@
matcher = patternRevertSubject.matcher(subject);
if (matcher.matches()) {
return MessageFormat.format(
- ChangeMessages.get().revertSubmissionOfRevertSubmissionUserMessage,
+ ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage,
2,
matcher.group(1),
changeNotes.getCurrentPatchSet().commitId().name());
}
return MessageFormat.format(
- ChangeMessages.get().revertSubmissionUserMessage, subject, initialMessage);
+ ChangeMessages.revertSubmissionUserMessage, subject, initialMessage);
}
/**
diff --git a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
index 6c8bf74..5b36950 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -68,8 +68,8 @@
private Map<String, CapabilityInfo> collectCoreCapabilities()
throws IllegalAccessException, NoSuchFieldException {
Map<String, CapabilityInfo> output = new HashMap<>();
- Class<? extends CapabilityConstants> bundleClass = CapabilityConstants.get().getClass();
- CapabilityConstants c = CapabilityConstants.get();
+ Class<? extends CapabilityConstants> bundleClass = new CapabilityConstants().getClass();
+ CapabilityConstants c = new CapabilityConstants();
for (String id : GlobalCapability.getAllNames()) {
String name = (String) bundleClass.getField(id).get(c);
output.put(id, new CapabilityInfo(id, name));
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
index 8be2646..912360c 100644
--- a/java/com/google/gerrit/server/restapi/project/ListLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -125,8 +125,7 @@
public List<LabelDefinitionInfo> filterLabelsThatUserCanVoteOnRef(
ProjectResource rsrc, List<LabelDefinitionInfo> allLabels)
- throws AuthException,
- PermissionBackendException,
+ throws PermissionBackendException,
ResourceConflictException,
RepositoryNotFoundException,
IOException {
@@ -161,11 +160,14 @@
continue;
}
+ // We assume that user is interested in labels that can be voted on if
+ // the user is the change owner.
Set<LabelPermission.WithValue> can =
permissionBackend
.currentUser()
.project(rsrc.getNameKey())
.ref(branchNameKey.branch())
+ .changeToBeCreated(/* isOwner= */ true)
.test(labelType);
for (LabelValue v : labelType.getValues()) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 83f36f9..5946725 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1828,7 +1828,7 @@
in.state = REMOVED;
ReviewerResult reviewerResult = gApi.changes().id(r.getChangeId()).addReviewer(in);
assertThat(reviewerResult.error)
- .isEqualTo(MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, groupName));
+ .isEqualTo(MessageFormat.format(ChangeMessages.groupRemovalIsNotAllowed, groupName));
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index fe3cb00..19c1b81 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -52,7 +52,9 @@
.GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+ ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE,
+ ExperimentFeaturesConstants
+ .GERRIT_BACKEND_FEATURE_USE_DIRECT_PUSH_CONTEXT_FOR_SUBMIT_ON_PUSH)
.inOrder();
// "GerritBackendFeature__check_implicit_merges_on_merge",
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index fe488f5..1bf0f55 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -53,6 +53,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
import com.google.common.collect.Streams;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -109,10 +110,12 @@
import com.google.gerrit.server.PluginPushOption;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.receive.NoteDbPushOption;
+import com.google.gerrit.server.git.receive.PushOptionsValidator;
import com.google.gerrit.server.git.receive.ReceiveConstants;
import com.google.gerrit.server.git.validators.CommitValidationInfo;
import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.project.testing.TestLabels;
import com.google.gerrit.server.query.change.ChangeData;
@@ -3190,6 +3193,38 @@
}
@Test
+ public void pushWithMalformedCustomKeyedValuesRejects() throws Exception {
+ List<String> pushOptions = new ArrayList<>();
+ pushOptions.add("custom-keyed-value=foo-bar");
+
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ push.setPushOptions(pushOptions);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertErrorStatus(
+ "the value for option 'custom-keyed-value' must be given as '<key>:<format>'");
+ }
+
+ @Test
+ public void pushWithCustomKeyedValuesPasses() throws Exception {
+ List<String> pushOptions = new ArrayList<>();
+ pushOptions.add("custom-keyed-value=foo:bar");
+
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ push.setPushOptions(pushOptions);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+ assertThat(r.getChange().customKeyedValues()).isEqualTo(ImmutableMap.of("foo", "bar"));
+ }
+
+ @Test
+ public void cannotPushWithCustomKeyedValuesInRefName() throws Exception {
+ PushOneCommit.Result r = pushTo("refs/for/master%custom-keyed-value=foo-bar");
+ r.assertErrorStatus(
+ "option 'custom-keyed-value' cannot be specified as an option in the ref name, use a push"
+ + " option instead ('-o custom-keyed-value=<key>:<value>')");
+ }
+
+ @Test
public void pushForMasterWithUnknownOption() throws Exception {
PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
push.setPushOptions(ImmutableList.of("unknown=foo"));
@@ -3334,6 +3369,104 @@
}
}
+ @Test
+ public void rejectPushOptionFromValidator() throws Exception {
+ PushOptionsValidator pushOptionsValidator =
+ (Project.NameKey projectName, String refName, ListMultimap<String, String> pushOptions) ->
+ pushOptions.containsKey("topic")
+ ? ImmutableList.of(
+ new ValidationMessage("setting a topic is not allowed", /* isError= */ true))
+ : ImmutableList.of();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(pushOptionsValidator)) {
+ // Push with a push option that is being rejected.
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ List<String> pushOptions = new ArrayList<>();
+ pushOptions.add("topic=myTopic");
+ push.setPushOptions(pushOptions);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertErrorStatus("setting a topic is not allowed");
+
+ // Push with an option in the refname that is being rejected.
+ r = pushTo("refs/for/master%topic=myTopic");
+ r.assertErrorStatus("setting a topic is not allowed");
+
+ // Pushing with another push options is fine.
+ pushOptions = new ArrayList<>();
+ pushOptions.add("notify=NONE");
+ push.setPushOptions(pushOptions);
+ r = push.to("refs/for/master");
+ r.assertOkStatus();
+
+ // Pushing with another option in the refname is fine.
+ r = pushTo("refs/for/master%notify=NONE");
+ r.assertOkStatus();
+ }
+ }
+
+ @Test
+ public void printMessageForPushOptionFromValidator() throws Exception {
+ PushOptionsValidator pushOptionsValidator =
+ (Project.NameKey projectName, String refName, ListMultimap<String, String> pushOptions) ->
+ pushOptions.containsKey("topic")
+ ? ImmutableList.of(
+ new ValidationMessage(
+ "setting a topic is deprecated", ValidationMessage.Type.WARNING))
+ : ImmutableList.of();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(pushOptionsValidator)) {
+ // Push with a push option that is being rejected.
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ List<String> pushOptions = new ArrayList<>();
+ pushOptions.add("topic=myTopic");
+ push.setPushOptions(pushOptions);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+ r.assertMessage("warning: setting a topic is deprecated");
+
+ // Push with an option in the refname that is being rejected.
+ r = pushTo("refs/for/master%topic=myTopic");
+ r.assertOkStatus();
+ r.assertMessage("warning: setting a topic is deprecated");
+
+ // Pushing with another push options doesn't print the message.
+ push = pushFactory.create(admin.newIdent(), testRepo);
+ pushOptions = new ArrayList<>();
+ pushOptions.add("notify=NONE");
+ push.setPushOptions(pushOptions);
+ r = push.to("refs/for/master");
+ r.assertOkStatus();
+ r.assertNotMessage("warning: setting a topic is deprecated");
+
+ // Pushing with another option in the refname doesn't print the message.
+ r = pushTo("refs/for/master%notify=NONE");
+ r.assertOkStatus();
+ r.assertNotMessage("warning: setting a topic is deprecated");
+ }
+ }
+
+ @Test
+ public void cannotSetPushJustificationWhenSubmitOptionIsNotUsed() throws Exception {
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ List<String> pushOptions = new ArrayList<>();
+ pushOptions.add("push-justification=test justification");
+ push.setPushOptions(pushOptions);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertErrorStatus(
+ "when pushing for a review a push justification can only be set when the"
+ + " 'submit' option is used");
+
+ // pushing with the submit option works
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+ .update();
+
+ r = push.to("refs/for/master%submit");
+ r.assertOkStatus();
+ }
+
private DraftInput newDraft(String path, int line, String message) {
DraftInput d = new DraftInput();
d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
index c059ae4..c675e2b 100644
--- a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
@@ -14,19 +14,32 @@
package com.google.gerrit.acceptance.git;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static java.util.function.Function.identity;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
import com.google.gerrit.testing.RefUpdateContextCollector;
+import com.google.inject.Inject;
import java.util.Map.Entry;
import org.junit.Rule;
import org.junit.Test;
public class DirectPushRefUpdateContextIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
+
@Rule
public RefUpdateContextCollector refUpdateContextCollector = new RefUpdateContextCollector();
@@ -69,4 +82,80 @@
assertThat(ctx.getUpdateType()).isEqualTo(RefUpdateType.DIRECT_PUSH);
assertThat(ctx.getJustification()).hasValue("test justification");
}
+
+ @Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ values = {"GerritBackendFeature__use_direct_push_context_for_submit_on_push"})
+ public void submitOnPushWithoutJustification_emptyJustification() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+ .update();
+
+ PushOneCommit push =
+ pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+ push.setPushOptions(ImmutableList.of("submit"));
+ PushOneCommit.Result result = push.to("refs/for/master");
+
+ result.assertOkStatus();
+ ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> updates =
+ refUpdateContextCollector.getContextsByUpdateType(RefUpdateType.DIRECT_PUSH);
+ ImmutableMap<String, ImmutableList<RefUpdateContext>> refUpdateContextsByRef =
+ updates.stream().collect(toImmutableMap(Entry::getKey, Entry::getValue));
+ assertThat(refUpdateContextsByRef.keySet())
+ .containsExactly("refs/heads/master", RefNames.changeMetaRef(result.getChange().getId()));
+
+ ImmutableList<RefUpdateContext> ctxts = refUpdateContextsByRef.get("refs/heads/master");
+ ImmutableListMultimap<RefUpdateContext.RefUpdateType, RefUpdateContext>
+ refUpdateContextsByType =
+ ctxts.stream()
+ .collect(toImmutableListMultimap(RefUpdateContext::getUpdateType, identity()));
+ assertThat(refUpdateContextsByType.keySet())
+ .containsExactly(RefUpdateType.DIRECT_PUSH, RefUpdateType.MERGE_CHANGE);
+ ImmutableList<RefUpdateContext> directPushRefUpdatesContexts =
+ refUpdateContextsByType.get(RefUpdateType.DIRECT_PUSH);
+ assertThat(directPushRefUpdatesContexts).hasSize(1);
+ RefUpdateContext directPushUpdateCtx = directPushRefUpdatesContexts.get(0);
+ assertThat(directPushUpdateCtx.getJustification()).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(
+ name = "experiments.enabled",
+ values = {"GerritBackendFeature__use_direct_push_context_for_submit_on_push"})
+ public void submitOnPushWithJustification_justificationStoredInContext() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
+ .update();
+
+ PushOneCommit push =
+ pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+ push.setPushOptions(ImmutableList.of("push-justification=test justification", "submit"));
+ PushOneCommit.Result result = push.to("refs/for/master");
+
+ result.assertOkStatus();
+ ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> updates =
+ refUpdateContextCollector.getContextsByUpdateType(RefUpdateType.DIRECT_PUSH);
+ ImmutableMap<String, ImmutableList<RefUpdateContext>> refUpdateContextsByRef =
+ updates.stream().collect(toImmutableMap(Entry::getKey, Entry::getValue));
+ assertThat(refUpdateContextsByRef.keySet())
+ .containsExactly("refs/heads/master", RefNames.changeMetaRef(result.getChange().getId()));
+
+ ImmutableList<RefUpdateContext> ctxts = refUpdateContextsByRef.get("refs/heads/master");
+ ImmutableListMultimap<RefUpdateContext.RefUpdateType, RefUpdateContext>
+ refUpdateContextsByType =
+ ctxts.stream()
+ .collect(toImmutableListMultimap(RefUpdateContext::getUpdateType, identity()));
+ assertThat(refUpdateContextsByType.keySet())
+ .containsExactly(RefUpdateType.DIRECT_PUSH, RefUpdateType.MERGE_CHANGE);
+ ImmutableList<RefUpdateContext> directPushRefUpdatesContexts =
+ refUpdateContextsByType.get(RefUpdateType.DIRECT_PUSH);
+ assertThat(directPushRefUpdatesContexts).hasSize(1);
+ RefUpdateContext directPushUpdateCtx = directPushRefUpdatesContexts.get(0);
+ assertThat(directPushUpdateCtx.getJustification()).hasValue("test justification");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index 2667851..d544de1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -17,6 +17,7 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.util.stream.Collectors.toList;
@@ -318,6 +319,27 @@
}
@Test
+ public void voteableOnRefForChangeOwners() throws Exception {
+ configLabel("foo", LabelFunction.NO_OP);
+ configLabel("bar", LabelFunction.NO_OP);
+
+ // Grant permissions to read config and vote on 'foo' label with full range (-2 to +2)
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+ .add(allowLabel("foo").ref("refs/heads/master").group(CHANGE_OWNER).range(-2, 2))
+ .update();
+
+ requestScopeOperations.setApiUser(user.id());
+
+ List<LabelDefinitionInfo> labels =
+ gApi.projects().name(project.get()).labels().withVoteableOnRef("refs/heads/master").get();
+
+ assertThat(labelNames(labels)).containsExactly("foo");
+ }
+
+ @Test
public void voteableOnRefRespectsBranchRestrictions() throws Exception {
projectOperations
.project(project)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 5248349..331d172 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 5248349d072fb4cff7d4a2eeea63bb652836c1f1
+Subproject commit 331d1728d9a74fa9cdffbba3c85645a36419eff6
diff --git a/plugins/webhooks b/plugins/webhooks
index 8cf6f0c..788cf1a 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 8cf6f0c18115f19ddaf6eb7aa2ba624d07ec107b
+Subproject commit 788cf1a335c25a96874b99b74d7e4183b111d2f1
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 000519b..261ca26 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -346,6 +346,13 @@
range: CommentRange | undefined;
}
+/** The detail of the 'range-selected-mouse-up' event dispatched by gr-diff. */
+export declare interface RangeSelectedEventDetail {
+ side: Side;
+ lineNum: LineNumber;
+ range: CommentRange | undefined;
+}
+
export declare interface ContentLoadNeededEventDetail {
lineRange: {
left: LineRange;
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index a952204..4e0fe9b 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -28,6 +28,8 @@
LABEL_CHANGE = 'labelchange',
SHOW_CHANGE = 'showchange',
SUBMIT_CHANGE = 'submitchange',
+ // Fires GerritView values such as 'change', 'dashboard', 'admin', ...
+ VIEW_CHANGE = 'view-change',
SHOW_REVISION_ACTIONS = 'show-revision-actions',
COMMIT_MSG_EDIT = 'commitmsgedit',
REVERT = 'revert',
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 58827fb..1e5736b 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -16,6 +16,7 @@
import '../gr-repo-commands/gr-repo-commands';
import '../gr-repo-dashboards/gr-repo-dashboards';
import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-submit-requirements/gr-repo-submit-requirements';
import '../gr-repo-list/gr-repo-list';
import '../gr-server-info/gr-server-info';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
@@ -214,7 +215,7 @@
${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
${this.renderRepoDetailList()} ${this.renderRepoCommands()}
${this.renderRepoAccess()} ${this.renderRepoDashboards()}
- ${this.renderServerInfo()}
+ ${this.renderRepoSubmitRequirements()} ${this.renderServerInfo()}
`;
}
@@ -449,6 +450,20 @@
`;
}
+ private renderRepoSubmitRequirements() {
+ if (this.view !== GerritView.REPO) return nothing;
+ if (this.repoViewState?.detail !== RepoDetailView.SUBMIT_REQUIREMENTS)
+ return nothing;
+
+ return html`
+ <div class="main table breadcrumbs">
+ <gr-repo-submit-requirements
+ .repo=${this.repoViewState.repo}
+ ></gr-repo-submit-requirements>
+ </div>
+ `;
+ }
+
private renderServerInfo() {
if (this.view !== GerritView.ADMIN) return nothing;
if (this.adminViewState?.adminView !== AdminChildView.SERVER_INFO)
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index 34e8d24..f26610d 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -170,7 +170,7 @@
);
assert.equal(
queryAndAssert<GrDropdownList>(element, '#pageSelect').items!.length,
- 7
+ 8
);
});
@@ -370,6 +370,12 @@
detailType: RepoDetailView.DASHBOARDS,
url: '/admin/repos/my-repo,dashboards',
},
+ {
+ name: 'Submit Requirements',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.SUBMIT_REQUIREMENTS,
+ url: '/admin/repos/my-repo,submit-requirements',
+ },
],
},
},
@@ -450,6 +456,14 @@
detailType: RepoDetailView.DASHBOARDS,
parent: 'my-repo' as RepoName,
},
+ {
+ text: 'Submit Requirements',
+ value: 'reposubmit-requirements',
+ view: GerritView.REPO,
+ url: '/admin/repos/my-repo,submit-requirements',
+ detailType: RepoDetailView.SUBMIT_REQUIREMENTS,
+ parent: 'my-repo' as RepoName,
+ },
];
const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
const selectedIsCurrentPageSpy = sinon.spy(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index e099184..de32b7f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -10,11 +10,12 @@
import {sharedStyles} from '../../../styles/shared-styles';
import {tableStyles} from '../../../styles/gr-table-styles';
import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
import {
DashboardType,
createDashboardUrl,
} from '../../../models/views/dashboard';
+import {when} from 'lit/directives/when.js';
interface DashboardRef {
section: string;
@@ -26,11 +27,11 @@
@property({type: String})
repo?: RepoName;
- @property({type: Boolean})
- _loading = true;
+ @state()
+ loading = true;
- @property({type: Array})
- _dashboards?: DashboardRef[];
+ @state()
+ dashboards?: DashboardRef[];
private readonly restApiService = getAppContext().restApiService;
@@ -43,23 +44,13 @@
display: block;
margin-bottom: var(--spacing-xxl);
}
- .loading #dashboards,
- #loadingContainer {
- display: none;
- }
- .loading #loadingContainer {
- display: block;
- }
`,
];
}
override render() {
- return html` <table
- id="list"
- class="genericList ${this._computeLoadingClass(this._loading)}"
- >
- <tbody>
+ return html` <table id="list" class="genericList">
+ <tbody id="dashboards">
<tr class="headerRow">
<th class="topHeader">Dashboard name</th>
<th class="topHeader">Dashboard title</th>
@@ -67,36 +58,39 @@
<th class="topHeader">Inherited from</th>
<th class="topHeader">Default</th>
</tr>
- <tr id="loadingContainer">
- <td>Loading...</td>
- </tr>
- </tbody>
- <tbody id="dashboards">
- ${(this._dashboards ?? []).map(
- item => html`
- <tr class="groupHeader">
- <td colspan="5">${item.section}</td>
- </tr>
- ${(item.dashboards ?? []).map(
- info => html`
- <tr class="table">
- <td class="name">
- <a href=${this._getUrl(info.project, info.id)}
- >${info.path}</a
- >
- </td>
- <td class="title">${info.title}</td>
- <td class="desc">${info.description}</td>
- <td class="inherited">
- ${this._computeInheritedFrom(
- info.project,
- info.defining_project
- )}
- </td>
- <td class="default">
- ${this._computeIsDefault(info.is_default)}
- </td>
+ ${when(
+ this.loading,
+ () => html`<tr id="loadingContainer">
+ <td>Loading...</td>
+ </tr>`,
+ () => html`
+ ${(this.dashboards ?? []).map(
+ item => html`
+ <tr class="groupHeader">
+ <td colspan="5">${item.section}</td>
</tr>
+ ${(item.dashboards ?? []).map(
+ info => html`
+ <tr class="table">
+ <td class="name">
+ <a href=${this.getUrl(info.project, info.id)}
+ >${info.path}</a
+ >
+ </td>
+ <td class="title">${info.title}</td>
+ <td class="desc">${info.description}</td>
+ <td class="inherited">
+ ${this.computeInheritedFrom(
+ info.project,
+ info.defining_project
+ )}
+ </td>
+ <td class="default">
+ ${this.computeIsDefault(info.is_default)}
+ </td>
+ </tr>
+ `
+ )}
`
)}
`
@@ -113,7 +107,7 @@
private repoChanged() {
const repo = this.repo;
- this._loading = true;
+ this.loading = true;
if (!repo) {
return Promise.resolve();
}
@@ -151,26 +145,22 @@
});
});
- this._dashboards = dashboardBuilder;
- this._loading = false;
+ this.dashboards = dashboardBuilder;
+ this.loading = false;
});
}
- _getUrl(project?: RepoName, dashboard?: DashboardId) {
+ private getUrl(project?: RepoName, dashboard?: DashboardId) {
if (!project || !dashboard) return '';
return createDashboardUrl({project, type: DashboardType.REPO, dashboard});
}
- _computeLoadingClass(loading: boolean) {
- return loading ? 'loading' : '';
- }
-
- _computeInheritedFrom(project: RepoName, definingProject: RepoName) {
+ private computeInheritedFrom(project: RepoName, definingProject: RepoName) {
return project === definingProject ? '' : definingProject;
}
- _computeIsDefault(isDefault?: boolean) {
+ private computeIsDefault(isDefault?: boolean) {
return isDefault ? '✓' : '';
}
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
index 2bbc28b..b09212e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -9,7 +9,7 @@
import {
addListenerForTest,
mockPromise,
- queryAndAssert,
+ query,
stubRestApi,
waitEventLoop,
} from '../../../test/test-utils';
@@ -83,8 +83,8 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <table class="genericList loading" id="list">
- <tbody>
+ <table class="genericList" id="list">
+ <tbody id="dashboards">
<tr class="headerRow">
<th class="topHeader">Dashboard name</th>
<th class="topHeader">Dashboard title</th>
@@ -96,34 +96,20 @@
<td>Loading...</td>
</tr>
</tbody>
- <tbody id="dashboards"></tbody>
</table>
`
);
});
test('loading, sections, and ordering', async () => {
- assert.isTrue(element._loading);
- assert.notEqual(
- getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
- 'none'
- );
- assert.equal(
- getComputedStyle(queryAndAssert(element, '#dashboards')).display,
- 'none'
- );
+ assert.isTrue(element.loading);
+ assert.isOk(query(element, '#loadingContainer'));
+
element.repo = 'test' as RepoName;
await waitEventLoop();
- assert.equal(
- getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
- 'none'
- );
- assert.notEqual(
- getComputedStyle(queryAndAssert(element, '#dashboards')).display,
- 'none'
- );
+ assert.isNotOk(query(element, '#loadingContainer'));
- const dashboard = element._dashboards!;
+ const dashboard = element.dashboards!;
assert.equal(dashboard.length, 2);
assert.equal(dashboard[0].section, 'custom');
assert.equal(dashboard[1].section, 'default');
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
new file mode 100644
index 0000000..6de8705
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
@@ -0,0 +1,129 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RepoName, SubmitRequirementInfo} from '../../../types/common';
+import {firePageError} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+
+@customElement('gr-repo-submit-requirements')
+export class GrRepoSubmitRequirements extends LitElement {
+ @property({type: String})
+ repo?: RepoName;
+
+ @state()
+ loading = true;
+
+ @state()
+ submitRequirements?: SubmitRequirementInfo[];
+
+ private readonly restApiService = getAppContext().restApiService;
+
+ static override get styles() {
+ return [
+ sharedStyles,
+ tableStyles,
+ css`
+ :host {
+ display: block;
+ margin-bottom: var(--spacing-xxl);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ return html` <table id="list" class="genericList">
+ <tbody>
+ <tr class="headerRow">
+ <th class="topHeader">Name</th>
+ <th class="topHeader">Description</th>
+ <th class="topHeader">Applicability Expression</th>
+ <th class="topHeader">Submittability Expression</th>
+ <th class="topHeader">Override Expression</th>
+ <th
+ class="topHeader"
+ title="Whether override is allowed in child projects"
+ >
+ Allow Override
+ </th>
+ </tr>
+ </tbody>
+ <tbody id="submit-requirements">
+ ${when(
+ this.loading,
+ () => html`<tr id="loadingContainer">
+ <td>Loading...</td>
+ </tr>`,
+ () =>
+ html` ${(this.submitRequirements ?? []).map(
+ item => html`
+ <tr class="table">
+ <td class="name">${item.name}</td>
+ <td class="desc">${item.description}</td>
+ <td class="applicability">
+ ${item.applicability_expression}
+ </td>
+ <td class="submittability">
+ ${item.submittability_expression}
+ </td>
+ <td class="override">${item.override_expression}</td>
+ <td class="allowOverride">
+ ${this.renderCheckmark(
+ item.allow_override_in_child_projects
+ )}
+ </td>
+ </tr>
+ `
+ )}`
+ )}
+ </tbody>
+ </table>`;
+ }
+
+ override updated(changedProperties: PropertyValues) {
+ if (changedProperties.has('repo')) {
+ this.repoChanged();
+ }
+ }
+
+ private repoChanged() {
+ const repo = this.repo;
+ this.loading = true;
+ if (!repo) {
+ return Promise.resolve();
+ }
+
+ const errFn: ErrorCallback = response => {
+ firePageError(response);
+ };
+
+ return this.restApiService
+ .getRepoSubmitRequirements(repo, errFn)
+ .then((res?: SubmitRequirementInfo[]) => {
+ if (!res) {
+ return;
+ }
+
+ this.submitRequirements = res;
+ this.loading = false;
+ });
+ }
+
+ private renderCheckmark(check?: boolean) {
+ return check ? '✓' : '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-submit-requirements': GrRepoSubmitRequirements;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
new file mode 100644
index 0000000..f438a68
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo-submit-requirements';
+import {GrRepoSubmitRequirements} from './gr-repo-submit-requirements';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {RepoName, SubmitRequirementInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-repo-submit-requirements tests', () => {
+ let element: GrRepoSubmitRequirements;
+
+ setup(async () => {
+ element = await fixture(
+ html`<gr-repo-submit-requirements></gr-repo-submit-requirements>`
+ );
+ });
+
+ suite('submit requirements table', () => {
+ setup(() => {
+ stubRestApi('getRepoSubmitRequirements').returns(
+ Promise.resolve([
+ {
+ name: 'Verified',
+ description: 'CI result status for build and tests is passing',
+ submittability_expression:
+ 'label:Verified=MAX AND -label:Verified=MIN',
+ },
+ ] as SubmitRequirementInfo[])
+ );
+ element.repo = 'test' as RepoName;
+ });
+
+ test('render loading', () => {
+ element.repo = 'test2' as RepoName;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `<table class="genericList" id="list">
+ <tbody>
+ <tr class="headerRow">
+ <th class="topHeader">Name</th>
+ <th class="topHeader">Description</th>
+ <th class="topHeader">Applicability Expression</th>
+ <th class="topHeader">Submittability Expression</th>
+ <th class="topHeader">Override Expression</th>
+ <th
+ class="topHeader"
+ title="Whether override is allowed in child projects"
+ >
+ Allow Override
+ </th>
+ </tr>
+ </tbody>
+ <tbody id="submit-requirements">
+ <tr id="loadingContainer">
+ <td>Loading...</td>
+ </tr>
+ </tbody>
+ </table>`
+ );
+ });
+
+ test('render', async () => {
+ await waitEventLoop();
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <table class="genericList" id="list">
+ <tbody>
+ <tr class="headerRow">
+ <th class="topHeader">Name</th>
+ <th class="topHeader">Description</th>
+ <th class="topHeader">Applicability Expression</th>
+ <th class="topHeader">Submittability Expression</th>
+ <th class="topHeader">Override Expression</th>
+ <th
+ class="topHeader"
+ title="Whether override is allowed in child projects"
+ >
+ Allow Override
+ </th>
+ </tr>
+ </tbody>
+ <tbody id="submit-requirements">
+ <tr class="table">
+ <td class="name">Verified</td>
+ <td class="desc">
+ CI result status for build and tests is passing
+ </td>
+ <td class="applicability"></td>
+ <td class="submittability">
+ label:Verified=MAX AND -label:Verified=MIN
+ </td>
+ <td class="override"></td>
+ <td class="allowOverride"></td>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index ae254be..a26d37f8f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -1670,7 +1670,7 @@
let expectedMsg =
'Revert submission 199 0' +
'\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>' +
'\n\n' +
'Reverted changes: /q/submissionid:199+0\n';
assert.equal(confirmRevertDialog.message, expectedMsg);
@@ -1683,7 +1683,7 @@
expectedMsg =
'Revert "random commit message"\n\nThis reverts ' +
'commit 2000.\n\nReason' +
- ' for revert: <INSERT REASONING HERE>\n';
+ ' for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(confirmRevertDialog.message, expectedMsg);
});
@@ -1733,13 +1733,13 @@
const revertSubmissionMsg =
'Revert submission 199 0' +
'\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>' +
'\n\n' +
'Reverted changes: /q/submissionid:199+0\n';
const singleChangeMsg =
'Revert "random commit message"\n\nThis reverts ' +
'commit 2000.\n\nReason' +
- ' for revert: <INSERT REASONING HERE>\n';
+ ' for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(confirmRevertDialog.message, revertSubmissionMsg);
const newRevertMsg = revertSubmissionMsg + 'random';
const newSingleChangeMsg = singleChangeMsg + 'random';
@@ -1833,7 +1833,7 @@
const msg =
'Revert "random commit message"\n\n' +
'This reverts commit 2000.\n\nReason ' +
- 'for revert: <INSERT REASONING HERE>\n';
+ 'for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(confirmRevertDialog.message, msg);
let editedMsg = msg + 'hello';
confirmRevertDialog.message += 'hello';
@@ -1852,10 +1852,10 @@
// Contains generic template reason so doesn't submit
assert.isFalse(fireActionStub.called);
confirmRevertDialog.message = confirmRevertDialog.message.replace(
- '<INSERT REASONING HERE>',
+ '<MUST SPECIFY REASON HERE>',
''
);
- editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
+ editedMsg = editedMsg.replace('<MUST SPECIFY REASON HERE>', '');
confirmButton.click();
await element.updateComplete;
assert.equal(fireActionStub.getCall(0).args[0], '/revert');
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 fb0b975..dec8def 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
@@ -563,7 +563,15 @@
subscribe(
this,
() => this.getViewModel().tab$,
- t => (this.activeTab = t)
+ t => {
+ // We don't want to jump during the initial page load. This scrolling
+ // works only when change is already loaded and user clicks on link
+ // to same page when tabs are not in view, but tabs needs to be rendered.
+ if (this.tabs) {
+ this.tabs.scrollIntoView({block: 'nearest'});
+ }
+ this.activeTab = t;
+ }
);
subscribe(
this,
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 3d69471..59ba256 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -24,9 +24,10 @@
import {ParsedChangeInfo} from '../../../types/types';
import {formStyles} from '../../../styles/form-styles';
import {GrValidationOptions} from '../gr-validation-options/gr-validation-options';
+import {parseCommitMessageString} from '../../../utils/commit-message-formatter-util';
const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
+const SPECIFY_REASON_STRING = '<MUST SPECIFY REASON HERE>';
// TODO(dhruvsri): clean up repeated definitions after moving to js modules
export enum RevertType {
@@ -230,27 +231,39 @@
commitMessage: string,
commitHash?: CommitId
) {
- // Figure out what the revert title should be.
- const originalTitle = (commitMessage || '').split('\n')[0];
- let revertTitle = `Revert "${originalTitle}"`;
- const match = originalTitle.match(/^Revert(?:\^([0-9]+))? "(.*)"$/);
- if (match) {
- let revertNum = 2;
- if (match[1]) {
- revertNum = Number(match[1]) + 1;
- }
- revertTitle = `Revert^${revertNum} "${match[2]}"`;
- }
-
if (!commitHash) {
fireAlert(this, ERR_COMMIT_NOT_FOUND);
return;
}
+
+ // Figure out what the revert title should be.
+ const originalTitle = (commitMessage || '').split('\n')[0];
+ let revertTitle = `Revert "${originalTitle}"`;
+ const revertTitleRegex = originalTitle.match(
+ /^Revert(?:\^([0-9]+))? "(.*)"$/
+ );
+
+ const footers = parseCommitMessageString(commitMessage).footer.filter(
+ f => f.startsWith('Issue: ') || f.startsWith('Bug: ')
+ );
+
+ if (revertTitleRegex) {
+ let revertNum = 2;
+ if (revertTitleRegex[1]) {
+ revertNum = Number(revertTitleRegex[1]) + 1;
+ }
+ revertTitle = `Revert^${revertNum} "${revertTitleRegex[2]}"`;
+ }
+
const revertCommitText = `This reverts commit ${commitHash}.`;
- const message =
+ let message =
`${revertTitle}\n\n${revertCommitText}\n\n` +
- `Reason for revert: ${INSERT_REASON_STRING}\n`;
+ `Reason for revert: ${SPECIFY_REASON_STRING}\n`;
+
+ if (footers.length > 0) {
+ message += '\n' + footers.join('\n'); // Empty line before the footers begin
+ }
// This is to give plugins a chance to update message
this.message = this.modifyRevertMsg(change, commitMessage, message);
this.revertType = RevertType.REVERT_SINGLE_CHANGE;
@@ -285,8 +298,7 @@
const message =
`Revert submission ${change.submission_id}` +
'\n\n' +
- 'Reason for revert: <INSERT ' +
- 'REASONING HERE>\n\n' +
+ `Reason for revert: ${SPECIFY_REASON_STRING}\n\n` +
'Reverted changes: ' +
createSearchUrl({query: `submissionid:${change.submission_id}`}) +
'\n';
@@ -326,7 +338,7 @@
e.stopPropagation();
if (
this.message === this.originalRevertMessages[this.revertType] ||
- this.message.includes(INSERT_REASON_STRING)
+ this.message.includes(SPECIFY_REASON_STRING)
) {
this.showErrorMessage = true;
return;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index f4abe0c..1e5a194 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -67,7 +67,7 @@
const expected =
'Revert "one line commit"\n\n' +
'This reverts commit abcd123.\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n';
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(element.message, expected);
});
@@ -81,7 +81,65 @@
const expected =
'Revert "many lines"\n\n' +
'This reverts commit abcd123.\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n';
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n';
+ assert.equal(element.message, expected);
+ });
+
+ test('populateRevertSingleChangeMessage parses Issues footer from commit message', () => {
+ assert.isNotOk(element.message);
+ element.populateRevertSingleChangeMessage(
+ createParsedChange(),
+ 'much lines\nvery\n\ncommit\nIssue: 1234567\nChange-Id: abcdefg\n',
+ 'abcd123' as CommitId
+ );
+ const expected =
+ 'Revert "much lines"\n\n' +
+ 'This reverts commit abcd123.\n\n' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
+ 'Issue: 1234567';
+ assert.equal(element.message, expected);
+ });
+
+ test('populateRevertSingleChangeMessage does not parse Issue: from commit message body', () => {
+ assert.isNotOk(element.message);
+ element.populateRevertSingleChangeMessage(
+ createParsedChange(),
+ 'much lines\n\nIssue: 1234567very\n\ncommit\nChange-Id: abcdefg\n',
+ 'abcd123' as CommitId
+ );
+ const expected =
+ 'Revert "much lines"\n\n' +
+ 'This reverts commit abcd123.\n\n' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n';
+ assert.equal(element.message, expected);
+ });
+
+ test('populateRevertSingleChangeMessage parses Bug footer from commit message', () => {
+ assert.isNotOk(element.message);
+ element.populateRevertSingleChangeMessage(
+ createParsedChange(),
+ 'much lines\nvery\n\ncommit\nBug: 1234567\nChange-Id: abcdefg\n',
+ 'abcd123' as CommitId
+ );
+ const expected =
+ 'Revert "much lines"\n\n' +
+ 'This reverts commit abcd123.\n\n' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
+ 'Bug: 1234567';
+ assert.equal(element.message, expected);
+ });
+
+ test('populateRevertSingleChangeMessage does not parse Bug: from commit message body', () => {
+ assert.isNotOk(element.message);
+ element.populateRevertSingleChangeMessage(
+ createParsedChange(),
+ 'much lines\n\nBug: 1234567very\n\ncommit\nChange-Id: abcdefg\n',
+ 'abcd123' as CommitId
+ );
+ const expected =
+ 'Revert "much lines"\n\n' +
+ 'This reverts commit abcd123.\n\n' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(element.message, expected);
});
@@ -95,7 +153,8 @@
const expected =
'Revert "much lines"\n\n' +
'This reverts commit abcd123.\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n';
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
+ 'Bug: Issue 42';
assert.equal(element.message, expected);
});
@@ -109,7 +168,7 @@
const expected =
'Revert^2 "one line commit"\n\n' +
'This reverts commit abcd123.\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n';
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(element.message, expected);
});
@@ -123,7 +182,7 @@
const expected =
'Revert^3 "one line commit"\n\n' +
'This reverts commit abcd123.\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n';
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n';
assert.equal(element.message, expected);
});
@@ -140,7 +199,7 @@
const expected =
'Revert submission 5545\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n\n' +
+ 'Reason for revert: <MUST SPECIFY REASON HERE>\n\n' +
'Reverted changes: /q/submissionid:5545\n';
assert.equal(element.message, expected);
});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 6a72d41..200db91 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -626,6 +626,11 @@
this.isOwner &&
// without fixes
!this.result?.fixes?.length &&
+ // disable on codepointer with range pointing to 0 line
+ this.result?.codePointers?.[0]?.range.start_line !== 0 &&
+ this.result?.codePointers?.[0]?.range.end_line !== 0 &&
+ this.result?.category !== Category.SUCCESS &&
+ this.result?.category !== Category.INFO &&
!fixAction
) {
getAiFixAction = createGetAiFixAction(this);
@@ -725,6 +730,8 @@
fireAlert(this, 'No suitable AI fix could be found');
return;
}
+ suggestion.description =
+ ReportSource.GET_AI_FIX_FOR_CHECK + ' ' + suggestion.description;
this.suggestion = suggestion;
this.toggleExpanded(/* setExpanded= */ true);
}
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 5a79b8d..0da5336 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -18,7 +18,7 @@
import './gr-checks-results';
import './gr-hovercard-run';
import {fontStyles} from '../../styles/gr-font-styles';
-import {Action} from '../../api/checks';
+import {Action, Category} from '../../api/checks';
import {assertIsDefined} from '../../utils/common-util';
import {resolve} from '../../models/dependency';
import {commentsModelToken} from '../../models/comments/comments-model';
@@ -298,6 +298,19 @@
if (this.result?.fixes?.length) {
return false;
}
+ if (
+ this.result?.category === Category.SUCCESS ||
+ this.result?.category === Category.INFO
+ ) {
+ return false;
+ }
+ // disable on codepointer with range pointing to 0 line
+ if (
+ this.result?.codePointers?.[0]?.range.start_line === 0 ||
+ this.result?.codePointers?.[0]?.range.end_line === 0
+ ) {
+ return false;
+ }
return this.isOwner;
}
@@ -383,6 +396,8 @@
fireAlert(this, 'No suitable AI fix could be found');
return;
}
+ suggestion.description =
+ ReportSource.GET_AI_FIX_FOR_CHECK + ' ' + suggestion.description;
this.suggestion = suggestion;
this.isExpanded = true;
}
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 1528374..9f87d99 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -167,6 +167,8 @@
// Matches /admin/repos/<repos>,access.
REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
+ REPO_SUBMIT_REQUIREMENTS: /^\/admin\/repos\/(.+),submit-requirements$/,
+
// Matches /admin/plugins with optional filter and offset.
PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
// Matches /admin/groups with optional filter and offset.
@@ -786,6 +788,12 @@
ctx => this.handleRepoDashboardsRoute(ctx)
);
+ this.mapRoute(
+ RoutePattern.REPO_SUBMIT_REQUIREMENTS,
+ 'handleRepoSubmitRequirementsRoute',
+ ctx => this.handleRepoSubmitRequirementsRoute(ctx)
+ );
+
this.mapRoute(RoutePattern.BRANCH_LIST, 'handleBranchListRoute', ctx =>
this.handleBranchListRoute(ctx)
);
@@ -1215,6 +1223,19 @@
this.reporting.setRepoName(repo);
}
+ handleRepoSubmitRequirementsRoute(ctx: PageContext) {
+ const repo = ctx.params[0] as RepoName;
+ const state: RepoViewState = {
+ view: GerritView.REPO,
+ detail: RepoDetailView.SUBMIT_REQUIREMENTS,
+ repo,
+ };
+ // Note that router model view must be updated before view models.
+ this.setState(state);
+ this.repoViewModel.setState(state);
+ this.reporting.setRepoName(repo);
+ }
+
handleBranchListRoute(ctx: PageContext) {
const state: RepoViewState = {
view: GerritView.REPO,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 6d61f85..81b1616 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -191,6 +191,7 @@
'handleRepoGeneralRoute',
'handleRepoListRoute',
'handleRepoRoute',
+ 'handleRepoSubmitRequirementsRoute',
'handleQueryLegacySuffixRoute',
'handleQueryRoute',
'handleRegisterRoute',
@@ -727,6 +728,15 @@
});
});
+ test('REPO_SUBMIT_REQUIREMENTS', async () => {
+ // REPO_SUBMIT_REQUIREMENTS: /^\/admin\/repos\/(.+),submit-requirements$/,
+ await checkUrlToState('/admin/repos/4321,submit-requirements', {
+ ...createRepoViewState(),
+ detail: RepoDetailView.SUBMIT_REQUIREMENTS,
+ repo: '4321' as RepoName,
+ });
+ });
+
test('BRANCH_LIST', async () => {
await checkUrlToState('/admin/repos/4321,branches', {
...createRepoBranchesViewState(),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 77b7e41..2b3be7f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -64,6 +64,7 @@
LineNumber,
LineSelectedEventDetail,
LOST,
+ RangeSelectedEventDetail,
RenderPreferences,
} from '../../../api/diff';
import {resolve} from '../../../models/dependency';
@@ -114,6 +115,7 @@
// prettier-ignore
'render': CustomEvent<{}>;
'create-comment': CustomEvent<CreateCommentEventDetail>;
+ 'range-selected-mouse-up': CustomEvent<RangeSelectedEventDetail>;
'is-blame-loaded-changed': ValueChangedEvent<boolean>;
'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
'edit-weblinks-changed': ValueChangedEvent<WebLinkInfo[] | undefined>;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index b08e9a5..89b2a6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -625,22 +625,6 @@
${
this.unresolved
? html`
- <gr-button
- id="ackBtn"
- link
- class="action ack"
- ?disabled=${this.saving}
- @click=${this.handleCommentAck}
- >Ack</gr-button
- >
- <gr-button
- id="doneBtn"
- link
- class="action done"
- ?disabled=${this.saving}
- @click=${this.handleCommentDone}
- >Done</gr-button
- >
${this.shouldShowAIFixButton()
? html`
<gr-button
@@ -657,6 +641,22 @@
>
`
: nothing}
+ <gr-button
+ id="ackBtn"
+ link
+ class="action ack"
+ ?disabled=${this.saving}
+ @click=${this.handleCommentAck}
+ >Ack</gr-button
+ >
+ <gr-button
+ id="doneBtn"
+ link
+ class="action done"
+ ?disabled=${this.saving}
+ @click=${this.handleCommentDone}
+ >Done</gr-button
+ >
`
: ''
}
@@ -1014,6 +1014,9 @@
fireAlert(this, 'No suitable AI fix could be found');
return;
}
+ // Description is used to identify the suggestion source in logs.
+ suggestion.description =
+ ReportSource.GET_AI_FIX_FOR_COMMENT + ' ' + suggestion.description;
this.suggestion = suggestion;
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 44f4f84..461928e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -662,6 +662,16 @@
</gr-button>
<gr-button
aria-disabled="false"
+ class="action ai-fix"
+ id="aiFixBtn"
+ link=""
+ role="button"
+ tabindex="0"
+ >
+ Get AI Fix
+ </gr-button>
+ <gr-button
+ aria-disabled="false"
class="action ack"
id="ackBtn"
link=""
@@ -680,16 +690,6 @@
>
Done
</gr-button>
- <gr-button
- aria-disabled="false"
- class="action ai-fix"
- id="aiFixBtn"
- link=""
- role="button"
- tabindex="0"
- >
- Get AI Fix
- </gr-button>
<gr-icon
icon="link"
class="copy link-icon"
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
index d6a01ab..c8365c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -99,6 +99,16 @@
);
});
+ test('Within one week', async () => {
+ await testDates(
+ '2015-07-22 03:34:14.985000000',
+ '2015-07-29 02:25:14.985000000',
+ 'Jul 29',
+ 'Jul 29 02:25',
+ 'Wednesday, Jul 29, 2015, 02:25:14'
+ );
+ });
+
test('More than 24 hours but less than six months', async () => {
await testDates(
'2015-07-29 20:34:14.985000000',
@@ -147,6 +157,16 @@
);
});
+ test('Within one week', async () => {
+ await testDates(
+ '2015-07-22 03:34:14.985000000',
+ '2015-07-29 02:25:14.985000000',
+ '07/29',
+ '07/29 02:25',
+ 'Wednesday, 07/29/15, 02:25:14'
+ );
+ });
+
test('More than 24 hours but less than six months', async () => {
await testDates(
'2015-07-29 20:34:14.985000000',
@@ -185,6 +205,16 @@
);
});
+ test('Within one week', async () => {
+ await testDates(
+ '2015-07-22 03:34:14.985000000',
+ '2015-07-29 02:25:14.985000000',
+ '07-29',
+ '07-29 02:25',
+ 'Wednesday, 2015-07-29, 02:25:14'
+ );
+ });
+
test('More than 24 hours but less than six months', async () => {
await testDates(
'2015-07-29 20:34:14.985000000',
@@ -223,6 +253,16 @@
);
});
+ test('Within one week', async () => {
+ await testDates(
+ '2015-07-22 03:34:14.985000000',
+ '2015-07-29 02:25:14.985000000',
+ '29. Jul',
+ '29. Jul 02:25',
+ 'Wednesday, 29.07.2015, 02:25:14'
+ );
+ });
+
test('More than 24 hours but less than six months', async () => {
await testDates(
'2015-07-29 20:34:14.985000000',
@@ -261,6 +301,16 @@
);
});
+ test('Within one week', async () => {
+ await testDates(
+ '2015-07-22 03:34:14.985000000',
+ '2015-07-29 02:25:14.985000000',
+ '29/07',
+ '29/07 02:25',
+ 'Wednesday, 29/07/2015, 02:25:14'
+ );
+ });
+
test('More than 24 hours but less than six months', async () => {
await testDates(
'2015-07-29 20:34:14.985000000',
@@ -270,6 +320,16 @@
'Monday, 15/06/2015, 03:25:14'
);
});
+
+ test('More than six months', async () => {
+ await testDates(
+ '2015-07-29 20:34:14.985000000',
+ '2016-08-15 03:25:14.985000000',
+ '15/08/2016',
+ '15/08/2016 03:25',
+ 'Monday, 15/08/2016, 03:25:14'
+ );
+ });
});
suite('STD + 12 hours time format preference', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 2ab8bc2..6a2df5f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -82,6 +82,17 @@
}
}
+ async handleViewChange(view: string) {
+ await this.waitForPluginsToLoad();
+ for (const cb of this._getEventCallbacks(EventType.VIEW_CHANGE)) {
+ try {
+ cb(view);
+ } catch (err: unknown) {
+ this.reportError(err, EventType.VIEW_CHANGE);
+ }
+ }
+ }
+
async handleShowChange(detail: ShowChangeDetail) {
if (!detail.change) return;
await this.waitForPluginsToLoad();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index aa830d8..ba14fd2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -49,6 +49,7 @@
handleShowChange(detail: ShowChangeDetail): Promise<void>;
handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
+ handleViewChange(view?: string): void;
modifyRevertMsg(
change: ChangeInfo,
revertMsg: string,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 75d6a57..7eff2f6e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -393,6 +393,17 @@
return;
}
+ const range = {
+ start_line: start.line,
+ start_character: start.column,
+ end_line: end.line,
+ end_character: end.column,
+ };
+ const side = start.side;
+ this.selectedRange = {range, side};
+ if (isMouseUp) {
+ this.diffBuilder?.diffModel.fireRangeSelectedMouseUpEvent(side, range);
+ }
let actionBox = this.diffTable.querySelector('gr-selection-action-box');
if (!actionBox) {
actionBox = document.createElement('gr-selection-action-box');
@@ -403,15 +414,6 @@
if (hoverCardText) {
actionBox.setAttribute('hoverCardText', hoverCardText);
}
- this.selectedRange = {
- range: {
- start_line: start.line,
- start_character: start.column,
- end_line: end.line,
- end_character: end.column,
- },
- side: start.side,
- };
if (start.line === end.line) {
this.positionActionBox(actionBox, start.line, domRange);
} else if (start.node instanceof Text) {
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 c802c0c..6678078 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
@@ -16,6 +16,7 @@
DisplayLine,
LineNumber,
LineSelectedEventDetail,
+ RangeSelectedEventDetail,
RenderPreferences,
Side,
SyntaxBlock,
@@ -315,4 +316,13 @@
};
fire(this.eventTarget, 'create-comment', detail);
}
+
+ fireRangeSelectedMouseUpEvent(side: Side, range: CommentRange) {
+ const detail: RangeSelectedEventDetail = {
+ side,
+ lineNum: range.end_line,
+ range,
+ };
+ fire(this.eventTarget, 'range-selected-mouse-up', detail);
+ }
}
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 164858d..880589a 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -275,6 +275,12 @@
detailType: RepoDetailView.DASHBOARDS,
url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
},
+ {
+ name: 'Submit Requirements',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.SUBMIT_REQUIREMENTS,
+ url: createRepoUrl({repo, detail: RepoDetailView.SUBMIT_REQUIREMENTS}),
+ },
],
};
}
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index eab362a..675706f 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -63,7 +63,7 @@
if (expected.projectPageShown) {
assert.isOk(res.links[0].subsection);
- assert.equal(res.links[0].subsection!.children!.length, 6);
+ assert.equal(res.links[0].subsection!.children!.length, 7);
} else {
assert.isNotOk(res.links[0].subsection);
}
@@ -103,7 +103,7 @@
}
if (expected.projectPageShown) {
assert.equal(res.expandedSection!.name, 'my-repo');
- assert.equal(res.expandedSection!.children!.length, 6);
+ assert.equal(res.expandedSection!.children!.length, 7);
} else if (expected.groupPageShown) {
assert.equal(res.expandedSection!.name, 'my-group');
assert.equal(
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 1629449..ded065d 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -17,6 +17,7 @@
COMMANDS = 'commands',
DASHBOARDS = 'dashboards',
TAGS = 'tags',
+ SUBMIT_REQUIREMENTS = 'submit-requirements',
}
export interface RepoViewState extends ViewState {
@@ -49,6 +50,8 @@
url += ',commands';
} else if (state.detail === RepoDetailView.DASHBOARDS) {
url += ',dashboards';
+ } else if (state.detail === RepoDetailView.SUBMIT_REQUIREMENTS) {
+ url += ',submit-requirements';
}
return getBaseUrl() + url;
}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 9996c75..ab2ae52 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -109,7 +109,7 @@
): Map<DependencyToken<unknown>, Creator<unknown>> {
return new Map<DependencyToken<unknown>, Creator<unknown>>([
[authServiceToken, () => appContext.authService],
- [routerModelToken, () => new RouterModel()],
+ [routerModelToken, () => new RouterModel(resolver(pluginLoaderToken))],
[userModelToken, () => new UserModel(appContext.restApiService)],
[browserModelToken, () => new BrowserModel(resolver(userModelToken))],
[accountsModelToken, () => new AccountsModel(appContext.restApiService)],
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 6beb2aa..4601bd2 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
@@ -106,6 +106,7 @@
DraftInfo,
ListChangesOption,
ReviewResult,
+ SubmitRequirementInfo,
} from '../../types/common';
import {
DiffInfo,
@@ -1773,6 +1774,17 @@
});
}
+ getRepoSubmitRequirements(
+ repoName: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<SubmitRequirementInfo[] | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url: `/projects/${encodeURIComponent(repoName)}/submit_requirements`,
+ errFn,
+ anonymizedUrl: '/projects/*/submit_requirements',
+ }) as Promise<SubmitRequirementInfo[] | undefined>;
+ }
+
getRepoAccessRights(
repoName: RepoName,
errFn?: ErrorCallback
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 8dbf1e9..ea6451a 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
@@ -91,6 +91,7 @@
UserId,
DraftInfo,
ReviewResult,
+ SubmitRequirementInfo,
} from '../../types/common';
import {
DiffInfo,
@@ -285,6 +286,11 @@
errFn?: ErrorCallback
): Promise<string[] | undefined>;
+ getRepoSubmitRequirements(
+ repoName: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<SubmitRequirementInfo[] | undefined>;
+
getRepoAccessRights(
repoName: RepoName,
errFn?: ErrorCallback
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 09ba661..5506fb1 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -7,6 +7,7 @@
import {Model} from '../../models/base/model';
import {select} from '../../utils/observable-util';
import {define} from '../../models/dependency';
+import {PluginLoader} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
export enum GerritView {
ADMIN = 'admin',
@@ -39,7 +40,12 @@
state => state.view
);
- constructor() {
+ constructor(private readonly pluginLoader: PluginLoader) {
super({});
+ this.subscriptions = [
+ this.routerView$.subscribe(view => {
+ this.pluginLoader.jsApiService.handleViewChange(view);
+ }),
+ ];
}
}
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 30bac0d..95f160d 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -60,6 +60,7 @@
NumericChangeId,
PreferencesInput,
DraftInfo,
+ SubmitRequirementInfo,
} from '../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
import {
@@ -393,6 +394,9 @@
getRepoAccess(): Promise<RepoAccessInfoMap | undefined> {
return Promise.resolve({});
},
+ getRepoSubmitRequirements(): Promise<SubmitRequirementInfo[] | undefined> {
+ return Promise.resolve([]);
+ },
getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
return Promise.resolve(undefined);
},
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index c88c07b..47669f4 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -727,6 +727,19 @@
}
/**
+ * The SubmitRequirementInfo entity describes a submit requirement.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-requirement-info
+ */
+export interface SubmitRequirementInfo {
+ name: string;
+ description?: string;
+ applicability_expression?: string;
+ submittability_expression: string;
+ override_expression?: string;
+ allow_override_in_child_projects: boolean;
+}
+
+/**
* The ProjectAccessInfo entity contains information about the access rights for
* a project.
* https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util.ts b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
index e1b3e27..02d8d2f 100644
--- a/polygerrit-ui/app/utils/commit-message-formatter-util.ts
+++ b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
@@ -319,7 +319,7 @@
return errors;
}
-function parseCommitMessageString(messageString: string): CommitMessage {
+export function parseCommitMessageString(messageString: string): CommitMessage {
const lines = messageString.split('\n');
// Remove leading blank lines
while (lines.length > 0 && lines[0].trim() === '') {
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index d95d24a..b0222d9 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -61,7 +61,7 @@
* Return true if date is within 24 hours and on the same day.
*/
export function isWithinDay(now: Date, date: Date) {
- const diff = now.valueOf() - date.valueOf();
+ const diff = Math.abs(now.valueOf() - date.valueOf());
return diff < Duration.DAY && date.getDay() === now.getDay();
}
@@ -79,7 +79,7 @@
* Returns true if date is from one to six months.
*/
export function isWithinHalfYear(now: Date, date: Date) {
- const diff = now.valueOf() - date.valueOf();
+ const diff = Math.abs(now.valueOf() - date.valueOf());
return diff < 180 * Duration.DAY;
}
diff --git a/resources/com/google/gerrit/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
deleted file mode 100644
index c78f5a3..0000000
--- a/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ /dev/null
@@ -1,13 +0,0 @@
-revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
-revertSubmissionDefaultMessage = This reverts commit {0}.
-revertSubmissionUserMessage = Revert \"{0}\"\n\n{1}
-revertSubmissionOfRevertSubmissionUserMessage = Revert^{0} \"{1}\"\n\n{2}
-
-reviewerCantSeeChange = {0} does not have permission to see this change
-reviewerInvalid = {0} is not a valid user identifier
-reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
-
-groupRemovalIsNotAllowed = Groups can't be removed from reviewers, so can't remove {0}.
-groupIsNotAllowed = The group {0} cannot be added as reviewer.
-groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
-groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
deleted file mode 100644
index 95206f9..0000000
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ /dev/null
@@ -1,25 +0,0 @@
-accessDatabase = Access Database
-administrateServer = Administrate Server
-batchChangesLimit = Batch Changes Limit
-createAccount = Create Account
-createGroup = Create Group
-deleteGroup = Delete Group
-createProject = Create Project
-emailReviewers = Email Reviewers
-flushCaches = Flush Caches
-killTask = Kill Task
-maintainServer = Maintain Server
-modifyAccount = Modify Account
-priority = Priority
-readAs = Read As
-queryLimit = Query Limit
-runAs = Run As
-runGC = Run Garbage Collection
-streamEvents = Stream Events
-viewAccess = View Access
-viewAllAccounts = View All Accounts
-viewCaches = View Caches
-viewConnections = View Connections
-viewPlugins = View Plugins
-viewQueue = View Queue
-viewSecondaryEmails = View Secondary Emails