From d92fdd5a0b6de9a1f40cf9ad6aaf43d562a66abb Mon Sep 17 00:00:00 2001 From: Ulrich Grave Date: Tue, 5 Aug 2025 11:24:55 +0200 Subject: [PATCH] Add option to always include branches, regardless of whether a pull request exists for those branches or not. --- .../bitbucket/trait/BranchDiscoveryTrait.java | 143 +++++++++++++++++- .../trait/BranchDiscoveryTrait/config.jelly | 3 + .../help-branchesAlwaysIncludedRegex.html | 3 + .../trait/BranchDiscoveryTraitTest.java | 142 +++++++++++++++++ 4 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/help-branchesAlwaysIncludedRegex.html diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait.java index 0f92db212..a7ce6e0e5 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait.java @@ -29,10 +29,17 @@ import com.cloudbees.jenkins.plugins.bitbucket.Messages; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketPullRequest; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketRepository; +import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; +import hudson.Util; +import hudson.model.Item; +import hudson.util.FormValidation; import hudson.util.ListBoxModel; import java.io.IOException; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import jenkins.model.Jenkins; import jenkins.scm.api.SCMHead; import jenkins.scm.api.SCMHeadCategory; import jenkins.scm.api.SCMHeadOrigin; @@ -48,7 +55,11 @@ import org.jenkinsci.Symbol; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; /** * A {@link Discovery} trait for bitbucket that will discover branches on the repository. @@ -67,6 +78,17 @@ public class BranchDiscoveryTrait extends SCMSourceTrait { */ private final int strategyId; + /** + * Regex of branches that should always be included regardless of whether a merge request exists or not. + */ + private String branchesAlwaysIncludedRegex; + + /** + * The compiled {@link Pattern} of the branchesAlwaysIncludedRegex. + */ + @CheckForNull + private transient Pattern branchesAlwaysIncludedRegexPattern; + /** * Constructor for stapler. * @@ -96,6 +118,36 @@ public int getStrategyId() { return strategyId; } + /** + * Returns the branchesAlwaysIncludedRegex. + * + * @return the branchesAlwaysIncludedRegex. + */ + public String getBranchesAlwaysIncludedRegex() { + return branchesAlwaysIncludedRegex; + } + + /** + * Sets the branchesAlwaysIncludedRegex. + */ + @DataBoundSetter + public void setBranchesAlwaysIncludedRegex(@CheckForNull String branchesAlwaysIncludedRegex) { + this.branchesAlwaysIncludedRegex = Util.fixEmptyAndTrim(branchesAlwaysIncludedRegex); + } + + /** + * Returns the compiled {@link Pattern} of the branchesAlwaysIncludedRegex. + * + * @return the branchesAlwaysIncludedRegexPattern. + */ + public Pattern getBranchesAlwaysIncludedRegexPattern() { + if (branchesAlwaysIncludedRegex != null && branchesAlwaysIncludedRegexPattern == null) { + branchesAlwaysIncludedRegexPattern = Pattern.compile(branchesAlwaysIncludedRegex); + } + + return branchesAlwaysIncludedRegexPattern; + } + /** * Returns {@code true} if building branches that are not filed as a PR. * @@ -127,11 +179,11 @@ protected void decorateContext(SCMSourceContext context) { switch (strategyId) { case 1: ctx.wantOriginPRs(true); - ctx.withFilter(new ExcludeOriginPRBranchesSCMHeadFilter()); + ctx.withFilter(new ExcludeOriginPRBranchesSCMHeadFilter(getBranchesAlwaysIncludedRegexPattern())); break; case 2: ctx.wantOriginPRs(true); - ctx.withFilter(new OnlyOriginPRBranchesSCMHeadFilter()); + ctx.withFilter(new OnlyOriginPRBranchesSCMHeadFilter(getBranchesAlwaysIncludedRegexPattern())); break; case 3: default: @@ -179,6 +231,27 @@ public ListBoxModel doFillStrategyIdItems() { result.add(Messages.BranchDiscoveryTrait_allBranches(), "3"); return result; } + + @NonNull + @Restricted(NoExternalUse.class) + @RequirePOST + public FormValidation doCheckBranchesAlwaysIncludedRegex(@CheckForNull @AncestorInPath Item context, @QueryParameter String value) { + if(context == null) { + Jenkins.get().checkPermission(Jenkins.MANAGE); + } else { + context.checkPermission(Item.CONFIGURE); + } + + if (value == null || value.isBlank()) { + return FormValidation.ok(); + } + try { + Pattern.compile(value); + return FormValidation.ok(); + } catch (PatternSyntaxException ex) { + return FormValidation.error(ex.getMessage()); + } + } } /** @@ -220,12 +293,41 @@ public boolean isApplicableToOrigin(@NonNull Class orig * Filter that excludes branches that are also filed as a pull request. */ public static class ExcludeOriginPRBranchesSCMHeadFilter extends SCMHeadFilter { + + /** + * The compiled {@link Pattern} of the branchesAlwaysIncludedRegex. + */ + private final Pattern branchesAlwaysIncludedRegexPattern; + + public ExcludeOriginPRBranchesSCMHeadFilter() { + branchesAlwaysIncludedRegexPattern = null; + } + + /** + * Constructor + * + * @param branchesAlwaysIncludedRegexPattern the branchesAlwaysIncludedRegexPattern. + */ + public ExcludeOriginPRBranchesSCMHeadFilter(Pattern branchesAlwaysIncludedRegexPattern) { + this.branchesAlwaysIncludedRegexPattern = branchesAlwaysIncludedRegexPattern; + } + /** * {@inheritDoc} */ @Override public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead head) { if (head instanceof BranchSCMHead && request instanceof BitbucketSCMSourceRequest) { + if (branchesAlwaysIncludedRegexPattern != null + && branchesAlwaysIncludedRegexPattern + .matcher(head.getName()) + .matches()) { + request.listener() + .getLogger() + .println("Include branch " + head.getName() + + " because branch name matches always included pattern"); + return false; + } BitbucketSCMSourceRequest req = (BitbucketSCMSourceRequest) request; String fullName = req.getRepoOwner() + "/" + req.getRepository(); try { @@ -251,12 +353,42 @@ public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead he * Filter that excludes branches that are not also filed as a pull request. */ public static class OnlyOriginPRBranchesSCMHeadFilter extends SCMHeadFilter { + + /** + * The compiled {@link Pattern} of the branchesAlwaysIncludedRegex. + */ + private final Pattern branchesAlwaysIncludedRegexPattern; + + public OnlyOriginPRBranchesSCMHeadFilter() { + branchesAlwaysIncludedRegexPattern = null; + } + + /** + * Constructor + * + * @param branchesAlwaysIncludedRegexPattern the branchesAlwaysIncludedRegexPattern. + */ + public OnlyOriginPRBranchesSCMHeadFilter(Pattern branchesAlwaysIncludedRegexPattern) { + this.branchesAlwaysIncludedRegexPattern = branchesAlwaysIncludedRegexPattern; + } + /** * {@inheritDoc} */ @Override public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead head) { if (head instanceof BranchSCMHead && request instanceof BitbucketSCMSourceRequest) { + if (branchesAlwaysIncludedRegexPattern != null + && branchesAlwaysIncludedRegexPattern + .matcher(head.getName()) + .matches()) { + request.listener() + .getLogger() + .println("Include branch " + head.getName() + + " because branch name matches always included pattern"); + return false; + } + BitbucketSCMSourceRequest req = (BitbucketSCMSourceRequest) request; String fullName = req.getRepoOwner() + "/" + req.getRepository(); try { @@ -267,8 +399,11 @@ public boolean isExcluded(@NonNull SCMSourceRequest request, @NonNull SCMHead he return false; } } - request.listener().getLogger().println("Discard branch " + head.getName() - + " because current strategy excludes branches that are not also filed as a pull request"); + request.listener() + .getLogger() + .println( + "Discard branch " + head.getName() + + " because current strategy excludes branches that are not also filed as a pull request"); return true; } catch (IOException | InterruptedException e) { // should never happens because data in the requests has been already initialised diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/config.jelly b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/config.jelly index 3076844a2..59ad933da 100644 --- a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/config.jelly +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/config.jelly @@ -5,4 +5,7 @@ + + + diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/help-branchesAlwaysIncludedRegex.html b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/help-branchesAlwaysIncludedRegex.html new file mode 100644 index 000000000..ffe3d54b7 --- /dev/null +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTrait/help-branchesAlwaysIncludedRegex.html @@ -0,0 +1,3 @@ +
+ Regular expression of branches that should always be included regardless of whether a pull request exists or not for those branches. +
diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTraitTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTraitTest.java index 121573024..528ae3361 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTraitTest.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/trait/BranchDiscoveryTraitTest.java @@ -24,23 +24,47 @@ package com.cloudbees.jenkins.plugins.bitbucket.trait; import com.cloudbees.jenkins.plugins.bitbucket.BitbucketSCMSourceContext; +import com.cloudbees.jenkins.plugins.bitbucket.BitbucketSCMSourceRequest; +import com.cloudbees.jenkins.plugins.bitbucket.BranchSCMHead; +import hudson.model.Item; +import hudson.model.TaskListener; +import hudson.model.User; +import hudson.security.ACL; +import hudson.security.ACLContext; +import hudson.security.AuthorizationStrategy; +import hudson.security.SecurityRealm; +import hudson.util.FormValidation; import hudson.util.ListBoxModel; +import java.io.PrintStream; import java.util.Collections; +import jenkins.model.Jenkins; +import jenkins.scm.api.SCMHead; import jenkins.scm.api.SCMHeadObserver; import jenkins.scm.api.trait.SCMHeadFilter; import jenkins.scm.api.trait.SCMHeadPrefilter; +import org.assertj.core.api.Assertions; import org.hamcrest.Matcher; import org.junit.ClassRule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.springframework.security.access.AccessDeniedException; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.hasToString; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; import static org.junit.Assume.assumeThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; public class BranchDiscoveryTraitTest { @ClassRule @@ -120,4 +144,122 @@ public void given__descriptor__when__displayingOptions__then__allThreePresent() assertThat(options.get(2).value, is("3")); } + @Test + public void given__context__with__AlwaysIncludePattern__shouldCompile() { + BranchDiscoveryTrait instance = new BranchDiscoveryTrait(false, true); + + assertThat(instance.getBranchesAlwaysIncludedRegexPattern(), is(nullValue())); + instance.setBranchesAlwaysIncludedRegex(""); + assertThat(instance.getBranchesAlwaysIncludedRegexPattern(), is(nullValue())); + + instance.setBranchesAlwaysIncludedRegex(".*myBranch"); + assertThat(instance.getBranchesAlwaysIncludedRegexPattern(), hasToString(".*myBranch")); + } + + @Test + public void given__excludingPRs__branch__match__alwaysIncludedRegex__shouldNotBeExcluded() throws Exception { + BitbucketSCMSourceContext ctx = new BitbucketSCMSourceContext(null, SCMHeadObserver.none()); + + BranchDiscoveryTrait instance = new BranchDiscoveryTrait(true, false); + instance.setBranchesAlwaysIncludedRegex(".*release$"); + instance.decorateContext(ctx); + assertThat(ctx.filters(), hasSize(1)); + SCMHeadFilter filter = ctx.filters().get(0); + assertThat(filter, instanceOf(BranchDiscoveryTrait.ExcludeOriginPRBranchesSCMHeadFilter.class)); + + SCMHead head = mock(BranchSCMHead.class); + when(head.getName()).thenReturn("feature/release"); + BitbucketSCMSourceRequest request = prepareRequest(); + + assertThat(filter.isExcluded(request, head), is(false)); + verify(request.listener().getLogger()) + .println("Include branch feature/release because branch name matches always included pattern"); + verifyNoMoreInteractions(request.listener().getLogger()); + } + + @Test + public void given__onlyPRs__branch__match__alwaysIncludedRegex__shouldNotBeExcluded() throws Exception { + BitbucketSCMSourceContext ctx = new BitbucketSCMSourceContext(null, SCMHeadObserver.none()); + + BranchDiscoveryTrait instance = new BranchDiscoveryTrait(false, true); + instance.setBranchesAlwaysIncludedRegex(".*release$"); + instance.decorateContext(ctx); + assertThat(ctx.filters(), hasSize(1)); + SCMHeadFilter filter = ctx.filters().get(0); + assertThat(filter, instanceOf(BranchDiscoveryTrait.OnlyOriginPRBranchesSCMHeadFilter.class)); + + SCMHead head = mock(BranchSCMHead.class); + when(head.getName()).thenReturn("feature/release"); + BitbucketSCMSourceRequest request = prepareRequest(); + + assertThat(filter.isExcluded(request, head), is(false)); + verify(request.listener().getLogger()) + .println("Include branch feature/release because branch name matches always included pattern"); + verifyNoMoreInteractions(request.listener().getLogger()); + } + + @Test + public void shouldValidateBranchesAlwaysIncludedRegexAsValid() { + Item item = mock(Item.class); + BranchDiscoveryTrait.DescriptorImpl descriptor = j.jenkins.getDescriptorByType(BranchDiscoveryTrait.DescriptorImpl.class); + + FormValidation formValidation = descriptor.doCheckBranchesAlwaysIncludedRegex(item, null); + assertThat(formValidation.kind, is(FormValidation.Kind.OK)); + + formValidation = descriptor.doCheckBranchesAlwaysIncludedRegex(item, ""); + assertThat(formValidation.kind, is(FormValidation.Kind.OK)); + + formValidation = descriptor.doCheckBranchesAlwaysIncludedRegex(item, " "); + assertThat(formValidation.kind, is(FormValidation.Kind.OK)); + + formValidation = descriptor.doCheckBranchesAlwaysIncludedRegex(item, ".*myBranch"); + assertThat(formValidation.kind, is(FormValidation.Kind.OK)); + + formValidation = descriptor.doCheckBranchesAlwaysIncludedRegex(null, ".*myBranch"); + assertThat(formValidation.kind, is(FormValidation.Kind.OK)); + + verify(item, times(4)).checkPermission(Item.CONFIGURE); + } + + @Test + public void shouldThrowPermissionDeniedIfNoContextAndNoManagePermission() { + BranchDiscoveryTrait.DescriptorImpl descriptor = j.jenkins.getDescriptorByType(BranchDiscoveryTrait.DescriptorImpl.class); + + SecurityRealm realm = j.jenkins.getSecurityRealm(); + AuthorizationStrategy strategy = j.jenkins.getAuthorizationStrategy(); + try { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MockAuthorizationStrategy mockStrategy = new MockAuthorizationStrategy(); + mockStrategy.grant(Jenkins.READ).onRoot().to("bob"); + j.jenkins.setAuthorizationStrategy(mockStrategy); + try (ACLContext context = ACL.as(User.getById("bob", false))) { + Assertions.assertThatThrownBy(() ->descriptor.doCheckBranchesAlwaysIncludedRegex(null, ".*myBranch")) + .isInstanceOf(AccessDeniedException.class); + } + } finally { + j.jenkins.setSecurityRealm(realm); + j.jenkins.setAuthorizationStrategy(strategy); + } + + } + + @Test + public void shouldValidateBranchesAlwaysIncludedRegexAsInvalid() { + Item item = mock(Item.class); + BranchDiscoveryTrait.DescriptorImpl descriptor = j.jenkins.getDescriptorByType(BranchDiscoveryTrait.DescriptorImpl.class); + + FormValidation formValidation = descriptor.doCheckBranchesAlwaysIncludedRegex(item, "invalidRegex/\\"); + assertThat(formValidation.kind, is(FormValidation.Kind.ERROR)); + + verify(item).checkPermission(Item.CONFIGURE); + } + + private BitbucketSCMSourceRequest prepareRequest() { + BitbucketSCMSourceRequest request = mock(BitbucketSCMSourceRequest.class); + TaskListener listener = mock(TaskListener.class); + when(listener.getLogger()).thenReturn(mock(PrintStream.class)); + when(request.listener()).thenReturn(listener); + return request; + } + }