feat: Add user criteria for branch update trigger (OD-2554)

This commit is contained in:
Robin Shen 2025-09-16 14:26:17 +08:00
parent 3939404717
commit 9b1db1c604
14 changed files with 141 additions and 71 deletions

View File

@ -1,9 +1,18 @@
package io.onedev.server.buildspec.job.trigger;
import java.util.Collection;
import java.util.List;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import io.onedev.commons.codeassist.InputSuggestion;
import io.onedev.commons.utils.match.Matcher;
import io.onedev.commons.utils.match.PathMatcher;
import io.onedev.server.OneDev;
import io.onedev.server.annotation.Editable;
import io.onedev.server.annotation.Patterns;
import io.onedev.server.annotation.UserMatch;
import io.onedev.server.buildspec.job.Job;
import io.onedev.server.buildspec.job.TriggerMatch;
import io.onedev.server.entitymanager.ProjectManager;
@ -11,15 +20,8 @@ import io.onedev.server.event.project.ProjectEvent;
import io.onedev.server.event.project.RefUpdated;
import io.onedev.server.git.GitUtils;
import io.onedev.server.model.Project;
import io.onedev.commons.utils.match.Matcher;
import io.onedev.commons.utils.match.PathMatcher;
import io.onedev.server.util.patternset.PatternSet;
import io.onedev.server.web.util.SuggestionUtils;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import java.util.Collection;
import java.util.List;
@Editable(order=100, name="Branch update", description=""
+ "Job will run when code is committed. <b class='text-info'>NOTE:</b> This trigger will ignore commits "
@ -33,7 +35,9 @@ public class BranchUpdateTrigger extends JobTrigger {
private String branches;
private String paths;
private String userMatch;
@Editable(name="Branches", order=100, placeholder="Any branch", description="Optionally specify space-separated branches "
+ "to check. Use '**' or '*' or '?' for <a href='https://docs.onedev.io/appendix/path-wildcard' target='_blank'>path wildcard match</a>. "
+ "Prefix with '-' to exclude. Leave empty to match all branches")
@ -63,6 +67,16 @@ public class BranchUpdateTrigger extends JobTrigger {
this.paths = paths;
}
@Editable(order=150, name="Applicable Users", placeholder = "Any user", description="Optionally specify applicable users who pushed the change")
@UserMatch
public String getUserMatch() {
return userMatch;
}
public void setUserMatch(String userMatch) {
this.userMatch = userMatch;
}
@SuppressWarnings("unused")
private static List<InputSuggestion> getPathSuggestions(String matchWith) {
return SuggestionUtils.suggestBlobs(Project.get(), matchWith);
@ -92,6 +106,14 @@ public class BranchUpdateTrigger extends JobTrigger {
return true;
}
}
private boolean pushedBy(RefUpdated refUpdated) {
if (getUserMatch() != null && refUpdated.getUser() != null) {
return io.onedev.server.util.usermatch.UserMatch.parse(getUserMatch()).matches(refUpdated.getUser());
} else {
return true;
}
}
@Override
protected TriggerMatch triggerMatches(ProjectEvent event, Job job) {
@ -102,7 +124,7 @@ public class BranchUpdateTrigger extends JobTrigger {
if (updatedBranch != null
&& !SKIP_COMMIT.apply(event.getProject().getRevCommit(refUpdated.getNewCommitId(), true))
&& (branches == null || PatternSet.parse(branches).matches(matcher, updatedBranch))
&& touchedFile(refUpdated)) {
&& touchedFile(refUpdated) && pushedBy(refUpdated)) {
return new TriggerMatch(refUpdated.getRefName(), null, null,
getParamMatrix(), getExcludeParamMaps(), "Branch '" + updatedBranch + "' is updated");
}
@ -113,12 +135,20 @@ public class BranchUpdateTrigger extends JobTrigger {
@Override
public String getTriggerDescription() {
String description;
if (getBranches() != null && getPaths() != null)
if (getBranches() != null && getPaths() != null && getUserMatch() != null)
description = String.format("When update branches '%s' and touch files '%s' and pushed by '%s'", getBranches(), getPaths(), getUserMatch());
else if (getBranches() != null && getUserMatch() != null)
description = String.format("When update branches '%s' and pushed by '%s'", getBranches(), getUserMatch());
else if (getPaths() != null && getUserMatch() != null)
description = String.format("When touch files '%s' and pushed by '%s'", getPaths(), getUserMatch());
else if (getBranches() != null && getPaths() != null)
description = String.format("When update branches '%s' and touch files '%s'", getBranches(), getPaths());
else if (getBranches() != null)
description = String.format("When update branches '%s'", getBranches());
else if (getPaths() != null)
description = String.format("When touch files '%s'", getPaths());
else if (getUserMatch() != null)
description = String.format("When pushed by '%s'", getUserMatch());
else
description = "When update branches";
return description;

View File

@ -6,6 +6,7 @@ import static io.onedev.server.web.translation.Translation._T;
import static java.util.stream.Collectors.toList;
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import static org.eclipse.jgit.lib.Constants.R_TAGS;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
@ -46,6 +47,7 @@ import io.onedev.server.buildspec.BuildSpec;
import io.onedev.server.cluster.ClusterTask;
import io.onedev.server.entitymanager.BuildManager;
import io.onedev.server.entitymanager.ProjectManager;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.event.ListenerRegistry;
import io.onedev.server.event.project.RefUpdated;
import io.onedev.server.git.CommandUtils;
@ -158,9 +160,16 @@ public class PullRepository extends SyncRepository {
if (!authorized)
throw new ExplicitException("This build is not authorized to sync to project: " + targetProject.getPath());
Long userId;
if (getAccessTokenSecret() != null) {
userId = build.getAccessToken(getAccessTokenSecret()).getOwner().getId();
} else {
userId = SecurityUtils.getUser().getId();
}
String remoteUrl = getRemoteUrlWithCredential(build);
Long targetProjectId = targetProject.getId();
var task = new PullTask(targetProjectId, remoteUrl, getCertificate(), getRefs(), isForce(), isWithLfs(), getProxy(), build.getSecretMasker());
var task = new PullTask(targetProjectId, userId, remoteUrl, getCertificate(), getRefs(), isForce(), isWithLfs(), getProxy(), build.getSecretMasker());
getProjectManager().runOnActiveServer(targetProjectId, task);
return new ServerStepResult(true);
});
@ -169,10 +178,16 @@ public class PullRepository extends SyncRepository {
private static ProjectManager getProjectManager() {
return OneDev.getInstance(ProjectManager.class);
}
private static UserManager getUserManager() {
return OneDev.getInstance(UserManager.class);
}
private static class PullTask implements ClusterTask<Void> {
private final Long projectId;
private final Long userId;
private final String remoteUrl;
@ -188,10 +203,11 @@ public class PullRepository extends SyncRepository {
private final SecretMasker secretMasker;
PullTask(Long projectId, String remoteUrl, @Nullable String certificate,
PullTask(Long projectId, Long userId, String remoteUrl, @Nullable String certificate,
String refs, boolean force, boolean withLfs, @Nullable String proxy,
SecretMasker secretMasker) {
this.projectId = projectId;
this.userId = userId;
this.remoteUrl = remoteUrl;
this.certificate = certificate;
this.refs = refs;
@ -370,19 +386,20 @@ public class PullRepository extends SyncRepository {
// Access db connection in a separate thread to avoid possible deadlock, as
// the parent thread is blocking another thread holding database connections
var project = getProjectManager().load(projectId);
var user = getUserManager().load(userId);
MapDifference<String, ObjectId> difference = difference(oldCommitIds, newCommitIds);
ListenerRegistry registry = OneDev.getInstance(ListenerRegistry.class);
for (Map.Entry<String, ObjectId> entry : difference.entriesOnlyOnLeft().entrySet()) {
if (RefUpdated.isValidRef(entry.getKey()))
registry.post(new RefUpdated(project, entry.getKey(), entry.getValue(), ObjectId.zeroId()));
registry.post(new RefUpdated(user, project, entry.getKey(), entry.getValue(), ObjectId.zeroId()));
}
for (Map.Entry<String, ObjectId> entry : difference.entriesOnlyOnRight().entrySet()) {
if (RefUpdated.isValidRef(entry.getKey()))
registry.post(new RefUpdated(project, entry.getKey(), ObjectId.zeroId(), entry.getValue()));
registry.post(new RefUpdated(user, project, entry.getKey(), ObjectId.zeroId(), entry.getValue()));
}
for (Map.Entry<String, ValueDifference<ObjectId>> entry : difference.entriesDiffering().entrySet()) {
if (RefUpdated.isValidRef(entry.getKey())) {
registry.post(new RefUpdated(project, entry.getKey(),
registry.post(new RefUpdated(user, project, entry.getKey(),
entry.getValue().leftValue(), entry.getValue().rightValue()));
}
}

View File

@ -1,18 +1,22 @@
package io.onedev.server.event.project;
import java.util.Date;
import javax.annotation.Nullable;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
import io.onedev.server.OneDev;
import io.onedev.server.git.GitUtils;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
import io.onedev.server.util.CommitAware;
import io.onedev.server.util.ProjectScopedCommit;
import io.onedev.server.util.commenttext.CommentText;
import io.onedev.server.util.commenttext.PlainText;
import io.onedev.server.web.UrlManager;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
import java.util.Date;
public class RefUpdated extends ProjectEvent implements CommitAware {
@ -26,12 +30,17 @@ public class RefUpdated extends ProjectEvent implements CommitAware {
private transient ProjectScopedCommit commit;
public RefUpdated(Project project, String refName, ObjectId oldCommitId, ObjectId newCommitId) {
super(null, new Date(), project);
public RefUpdated(@Nullable User user, Project project, String refName,
ObjectId oldCommitId, ObjectId newCommitId) {
super(user, new Date(), project);
this.refName = refName;
this.oldCommitId = oldCommitId;
this.newCommitId = newCommitId;
}
public RefUpdated(Project project, String refName, ObjectId oldCommitId, ObjectId newCommitId) {
this(null, project, refName, oldCommitId, newCommitId);
}
public static boolean isValidRef(String refName) {
return refName.startsWith(Constants.R_HEADS) || refName.startsWith(Constants.R_TAGS);

View File

@ -3,6 +3,7 @@ package io.onedev.server.git.hook;
import com.google.common.base.Preconditions;
import io.onedev.commons.utils.StringUtils;
import io.onedev.server.entitymanager.ProjectManager;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.event.ListenerRegistry;
import io.onedev.server.event.project.RefUpdated;
import io.onedev.server.git.GitUtils;
@ -43,6 +44,8 @@ public class GitPostReceiveCallback extends HttpServlet {
public static final String PATH = "/git-postreceive-callback";
private final ProjectManager projectManager;
private final UserManager userManager;
private final UrlManager urlManager;
@ -52,11 +55,12 @@ public class GitPostReceiveCallback extends HttpServlet {
@Inject
public GitPostReceiveCallback(ProjectManager projectManager, UrlManager urlManager,
SessionManager sessionManager, ListenerRegistry listenerRegistry) {
SessionManager sessionManager, ListenerRegistry listenerRegistry, UserManager userManager) {
this.projectManager = projectManager;
this.urlManager = urlManager;
this.sessionManager = sessionManager;
this.listenerRegistry = listenerRegistry;
this.userManager = userManager;
}
@Sessional
@ -139,12 +143,14 @@ public class GitPostReceiveCallback extends HttpServlet {
fields.set(pos, field);
}
var userId = SecurityUtils.getUser().getId();
sessionManager.runAsyncAfterCommit(() -> {
Project project = projectManager.load(projectId);
try {
for (var updateInfo: updateInfos) {
RefUpdated event = new RefUpdated(project, updateInfo.getLeft(),
updateInfo.getMiddle(), updateInfo.getRight());
RefUpdated event = new RefUpdated(userManager.load(userId), project,
updateInfo.getLeft(), updateInfo.getMiddle(), updateInfo.getRight());
listenerRegistry.invokeListeners(event);
}
} catch (Exception e) {

View File

@ -1342,7 +1342,7 @@ public class Project extends AbstractEntity implements LabelSupport<ProjectLabel
public TagProtection getTagProtection(String tagName, User user) {
for (TagProtection protection: getHierarchyTagProtections()) {
if (protection.isEnabled()
&& UserMatch.parse(protection.getUserMatch()).matches(this, user)
&& (protection.getUserMatch() == null || UserMatch.parse(protection.getUserMatch()).matches(user))
&& PatternSet.parse(protection.getTags()).matches(new PathMatcher(), tagName)) {
return protection;
}
@ -1361,7 +1361,7 @@ public class Project extends AbstractEntity implements LabelSupport<ProjectLabel
branchName = "main";
for (BranchProtection protection: getHierarchyBranchProtections()) {
if (protection.isEnabled()
&& UserMatch.parse(protection.getUserMatch()).matches(this, user)
&& (protection.getUserMatch() == null || UserMatch.parse(protection.getUserMatch()).matches(user))
&& PatternSet.parse(protection.getBranches()).matches(new PathMatcher(), branchName)) {
return protection;
}

View File

@ -41,7 +41,6 @@ import io.onedev.server.model.User;
import io.onedev.server.util.patternset.PatternSet;
import io.onedev.server.util.reviewrequirement.ReviewRequirement;
import io.onedev.server.util.usage.Usage;
import io.onedev.server.util.usermatch.Anyone;
import io.onedev.server.util.usermatch.UserMatch;
import io.onedev.server.web.util.SuggestionUtils;
@ -58,7 +57,7 @@ public class BranchProtection implements Serializable {
private String branches;
private String userMatch = new Anyone().toString();
private String userMatch;
private boolean preventForcedPush = true;
@ -117,9 +116,8 @@ public class BranchProtection implements Serializable {
return SuggestionUtils.suggestBranches(Project.get(), matchWith);
}
@Editable(order=150, name="Applicable Users", description="Rule will apply only if the user changing the branch matches criteria specified here")
@Editable(order=150, name="Applicable Users", placeholder = "Any user", description="Rule will apply only if the user changing the branch matches criteria specified here")
@io.onedev.server.annotation.UserMatch
@NotEmpty(message="may not be empty")
public String getUserMatch() {
return userMatch;
}
@ -311,7 +309,8 @@ public class BranchProtection implements Serializable {
}
public void onRenameGroup(String oldName, String newName) {
userMatch = UserMatch.onRenameGroup(userMatch, oldName, newName);
if (userMatch != null)
userMatch = UserMatch.onRenameGroup(userMatch, oldName, newName);
reviewRequirement = ReviewRequirement.onRenameGroup(reviewRequirement, oldName, newName);
for (FileProtection fileProtection: getFileProtections()) {
@ -322,7 +321,7 @@ public class BranchProtection implements Serializable {
public Usage onDeleteGroup(String groupName) {
Usage usage = new Usage();
if (UserMatch.isUsingGroup(userMatch, groupName))
if (userMatch != null && UserMatch.isUsingGroup(userMatch, groupName))
usage.add("applicable users");
if (ReviewRequirement.isUsingGroup(reviewRequirement, groupName))
usage.add("required reviewers");
@ -337,7 +336,8 @@ public class BranchProtection implements Serializable {
}
public void onRenameUser(String oldName, String newName) {
userMatch = UserMatch.onRenameUser(userMatch, oldName, newName);
if (userMatch != null)
userMatch = UserMatch.onRenameUser(userMatch, oldName, newName);
reviewRequirement = ReviewRequirement.onRenameUser(reviewRequirement, oldName, newName);
for (FileProtection fileProtection: getFileProtections()) {
@ -348,7 +348,7 @@ public class BranchProtection implements Serializable {
public Usage onDeleteUser(String userName) {
Usage usage = new Usage();
if (UserMatch.isUsingUser(userMatch, userName))
if (userMatch != null && UserMatch.isUsingUser(userMatch, userName))
usage.add("applicable users");
if (ReviewRequirement.isUsingUser(reviewRequirement, userName))
usage.add("required reviewers");

View File

@ -1,20 +1,20 @@
package io.onedev.server.model.support.code;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.NotEmpty;
import io.onedev.commons.codeassist.InputSuggestion;
import io.onedev.server.annotation.Editable;
import io.onedev.server.annotation.Patterns;
import io.onedev.server.model.Project;
import io.onedev.server.util.patternset.PatternSet;
import io.onedev.server.util.usage.Usage;
import io.onedev.server.util.usermatch.Anyone;
import io.onedev.server.util.usermatch.UserMatch;
import io.onedev.server.web.util.SuggestionUtils;
import javax.validation.constraints.NotEmpty;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Editable
public class TagProtection implements Serializable {
@ -24,7 +24,7 @@ public class TagProtection implements Serializable {
private String tags;
private String userMatch = new Anyone().toString();
private String userMatch;
private boolean preventUpdate = true;
@ -59,9 +59,8 @@ public class TagProtection implements Serializable {
return SuggestionUtils.suggestTags(Project.get(), matchWith);
}
@Editable(order=150, name="Applicable Users", description="Rule will apply if user operating the tag matches criteria specified here")
@Editable(order=150, name="Applicable Users", placeholder = "Any user", description="Rule will apply if user operating the tag matches criteria specified here")
@io.onedev.server.annotation.UserMatch
@NotEmpty(message="may not be empty")
public String getUserMatch() {
return userMatch;
}
@ -116,23 +115,25 @@ public class TagProtection implements Serializable {
}
public void onRenameGroup(String oldName, String newName) {
userMatch = UserMatch.onRenameGroup(userMatch, oldName, newName);
if (userMatch != null)
userMatch = UserMatch.onRenameGroup(userMatch, oldName, newName);
}
public Usage onDeleteGroup(String groupName) {
Usage usage = new Usage();
if (UserMatch.isUsingGroup(userMatch, groupName))
if (userMatch != null && UserMatch.isUsingGroup(userMatch, groupName))
usage.add("applicable users");
return usage.prefix("tag protection '" + getTags() + "'").prefix("code");
}
public void onRenameUser(String oldName, String newName) {
userMatch = UserMatch.onRenameUser(userMatch, oldName, newName);
if (userMatch != null)
userMatch = UserMatch.onRenameUser(userMatch, oldName, newName);
}
public Usage onDeleteUser(String userName) {
Usage usage = new Usage();
if (UserMatch.isUsingUser(userMatch, userName))
if (userMatch != null && UserMatch.isUsingUser(userMatch, userName))
usage.add("applicable users");
return usage.prefix("tag protection '" + getTags() + "'").prefix("code");
}

View File

@ -1,6 +1,5 @@
package io.onedev.server.util.usermatch;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
public class Anyone implements UserMatchCriteria {
@ -8,7 +7,7 @@ public class Anyone implements UserMatchCriteria {
private static final long serialVersionUID = 1L;
@Override
public boolean matches(Project project, User user) {
public boolean matches(User user) {
return true;
}

View File

@ -2,7 +2,6 @@ package io.onedev.server.util.usermatch;
import io.onedev.commons.utils.StringUtils;
import io.onedev.server.model.Group;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
public class GroupCriteria implements UserMatchCriteria {
@ -20,7 +19,7 @@ public class GroupCriteria implements UserMatchCriteria {
}
@Override
public boolean matches(Project project, User user) {
public boolean matches(User user) {
return group.getMembers().contains(user);
}

View File

@ -1,7 +1,6 @@
package io.onedev.server.util.usermatch;
import io.onedev.commons.utils.StringUtils;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
public class UserCriteria implements UserMatchCriteria {
@ -19,7 +18,7 @@ public class UserCriteria implements UserMatchCriteria {
}
@Override
public boolean matches(Project project, User user) {
public boolean matches(User user) {
return this.user.equals(user);
}

View File

@ -1,5 +1,21 @@
package io.onedev.server.util.usermatch;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.antlr.v4.runtime.BailErrorStrategy;
import org.antlr.v4.runtime.BaseErrorListener;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.RecognitionException;
import org.antlr.v4.runtime.Recognizer;
import org.antlr.v4.runtime.tree.TerminalNode;
import io.onedev.commons.codeassist.FenceAware;
import io.onedev.commons.utils.ExplicitException;
import io.onedev.commons.utils.StringUtils;
@ -7,19 +23,10 @@ import io.onedev.server.OneDev;
import io.onedev.server.entitymanager.GroupManager;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.model.Group;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
import io.onedev.server.util.usermatch.UserMatchParser.CriteriaContext;
import io.onedev.server.util.usermatch.UserMatchParser.ExceptCriteriaContext;
import io.onedev.server.util.usermatch.UserMatchParser.UserMatchContext;
import org.antlr.v4.runtime.*;
import org.antlr.v4.runtime.tree.TerminalNode;
import javax.annotation.Nullable;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class UserMatch implements Serializable {
@ -42,13 +49,13 @@ public class UserMatch implements Serializable {
return exceptCriterias;
}
public boolean matches(Project project, User user) {
public boolean matches(User user) {
for (UserMatchCriteria criteria: exceptCriterias) {
if (criteria.matches(project, user))
if (criteria.matches(user))
return false;
}
for (UserMatchCriteria criteria: criterias) {
if (criteria.matches(project, user))
if (criteria.matches(user))
return true;
}
return false;

View File

@ -2,13 +2,12 @@ package io.onedev.server.util.usermatch;
import java.io.Serializable;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
import io.onedev.server.annotation.Editable;
import io.onedev.server.model.User;
@Editable
public interface UserMatchCriteria extends Serializable {
boolean matches(Project project, User user);
boolean matches(User user);
}

View File

@ -311,7 +311,8 @@ public class MarkdownEditor extends FormComponentPanel<String> {
public void renderHead(IHeaderResponse response) {
super.renderHead(response);
String script = String.format(
"onedev.server.markdown.initRendered('%s');", container.getMarkupId());
"onedev.server.markdown.initRendered('%s', %s);",
container.getMarkupId(), JavascriptTranslations.get());
response.render(OnDomReadyHeaderItem.forScript(script));
}

View File

@ -1024,7 +1024,10 @@ onedev.server.markdown = {
onedev.server.markdown.syncPreviewScroll(containerId);
});
},
initRendered: function(containerId) {
initRendered: function(containerId, translations) {
if (translations)
onedev.server.markdown.translations = translations;
var $container = $("#" + containerId);
var $rendered = $container.find(".markdown-rendered");