diff --git a/src/main/java/jenkins/branch/OrganizationFolder.java b/src/main/java/jenkins/branch/OrganizationFolder.java index 697e3249..ba91eb1b 100644 --- a/src/main/java/jenkins/branch/OrganizationFolder.java +++ b/src/main/java/jenkins/branch/OrganizationFolder.java @@ -111,6 +111,9 @@ import static jenkins.scm.api.SCMEvent.Type.CREATED; import static jenkins.scm.api.SCMEvent.Type.UPDATED; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** * A folder-like collection of {@link MultiBranchProject}s, one per repository. */ @@ -145,6 +148,21 @@ public final class OrganizationFolder extends ComputedFolder sources = new ArrayList<>(); @Override @@ -1358,35 +1431,54 @@ private boolean recognizes(Map attributes, MultiBranchProjectFac @Override public void complete() throws IllegalStateException, IOException, InterruptedException { + String projectNamePatten = getProjectNamePattern(); + LOGGER.info("createMultipleProjects: " + createMultipleProjects); + LOGGER.info("projectNamePatten: " + projectNamePatten); try { - MultiBranchProjectFactory factory = null; + LOGGER.info("Scan " + projectName + " ..."); Map attributes = Collections.emptyMap(); for (MultiBranchProjectFactory candidateFactory : projectFactories) { boolean recognizes = recognizes(attributes, candidateFactory); LOGGER.fine(() -> candidateFactory + " recognizes " + projectName + " with " + attributes + "? " + recognizes); if (recognizes) { - factory = candidateFactory; - break; - } - } - if (factory == null) { - return; - } - String folderName = NameEncoder.encode(projectName); - // HACK: observer.shouldUpdate will restore the buildable flag of the child, so pre-inspect - MultiBranchProject existing = items.get(folderName); - boolean wasBuildable = existing != null && existing.isBuildable(); - boolean wasDisabled = existing != null && existing.isDisabled(); - // END_HACK: now that we know if it was buildable, we can now proceed to see about updating - existing = observer.shouldUpdate(folderName); - try { - if (existing != null) { - completeExisting(factory, attributes, existing, wasBuildable, wasDisabled); - } else { - completeNew(factory, attributes, folderName); + newProjectName = projectName; + if (createMultipleProjects && projectNamePatten != null && !projectNamePatten.isEmpty()) { + try { + String scriptPath = (String) candidateFactory.getClass().getMethod("getScriptPath").invoke(candidateFactory); + LOGGER.info("scriptPath: " + scriptPath); + Pattern pattern = Pattern.compile(projectNamePatten); + Matcher matcher = pattern.matcher(scriptPath); + if (matcher.matches()) { + newProjectName = projectName + matcher.group(1); + LOGGER.info("newProjectName: " + newProjectName); + } + } catch (Exception e) { + LOGGER.warning(() -> "Failed to get new project name from " + candidateFactory + ": " + e.getMessage()); + continue; + } + } + + String folderName = NameEncoder.encode(newProjectName); + // HACK: observer.shouldUpdate will restore the buildable flag of the child, so pre-inspect + MultiBranchProject existing = items.get(folderName); + boolean wasBuildable = existing != null && existing.isBuildable(); + boolean wasDisabled = existing != null && existing.isDisabled(); + // END_HACK: now that we know if it was buildable, we can now proceed to see about updating + existing = observer.shouldUpdate(folderName); + try { + if (existing != null) { + completeExisting(candidateFactory, attributes, existing, wasBuildable, wasDisabled); + } else { + completeNew(candidateFactory, attributes, folderName); + } + } finally { + observer.completed(folderName); + } + + if (!createMultipleProjects) { + break; + } } - } finally { - observer.completed(folderName); } } catch (InterruptedException | IOException x) { throw x; @@ -1402,10 +1494,11 @@ private void completeExisting(MultiBranchProjectFactory factory, Map folderProperty : getProperties()) { if (folderProperty instanceof OrganizationFolderProperty) { ((OrganizationFolderProperty) folderProperty).applyDecoration(existing, @@ -1441,9 +1534,9 @@ private void completeNew(MultiBranchProjectFactory factory, Map BulkChange bc = new BulkChange(project); try { if (!projectName.equals(folderName)) { - project.setDisplayName(projectName); + project.setDisplayName(newProjectName); } - project.addProperty(new ProjectNameProperty(projectName)); + project.addProperty(new ProjectNameProperty(newProjectName)); project.getSourcesList().addAll(createBranchSources()); for (AbstractFolderProperty property: getProperties()) { if (property instanceof OrganizationFolderProperty) { diff --git a/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.jelly b/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.jelly index 6b608245..0a58f39a 100644 --- a/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.jelly +++ b/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.jelly @@ -28,6 +28,14 @@ + + + + + + + + diff --git a/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.properties b/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.properties new file mode 100644 index 00000000..28c78511 --- /dev/null +++ b/src/main/resources/jenkins/branch/OrganizationFolder/configure-entries.properties @@ -0,0 +1,5 @@ +CreateMultipleProjects.DisplayName=Create Multiple Projects +CreateMultipleProjects.Description=Enable multiple projects creation of the same repository based on Jenkinsfile path pattern + +ProjectNamePattern.DisplayName=Project Name Pattern +ProjectNamePattern.Description=Regular expression pattern to extract project name suffix from Jenkinsfile path. Default pattern extracts suffix after last hyphen (e.g., 'Jenkinsfile-win' creates 'myproject-win') diff --git a/src/main/resources/jenkins/branch/OrganizationFolder/help-createMultipleProjects.html b/src/main/resources/jenkins/branch/OrganizationFolder/help-createMultipleProjects.html new file mode 100644 index 00000000..70e547b7 --- /dev/null +++ b/src/main/resources/jenkins/branch/OrganizationFolder/help-createMultipleProjects.html @@ -0,0 +1,26 @@ +
+ Controls whether to create multiple projects of the same repository based on Jenkinsfile path patterns. + +

+ When enabled, the system will examine Jenkinsfile paths and create separate projects + for different variants of the same repository based on the configured pattern. +

+ +

Example

+

+ If you have Jenkinsfiles like: +

    +
  • Jenkinsfile (main build)
  • +
  • Jenkinsfile-win (Windows-specific build)
  • +
  • Jenkinsfile-mac (Mac-specific build)
  • +
+

+

+ With the default pattern .*?(-[^.]+).*, this would create: +

    +
  • myproject from Jenkinsfile
  • +
  • myproject-win from Jenkinsfile-win
  • +
  • myproject-mac from Jenkinsfile-mac
  • +
+

+
\ No newline at end of file diff --git a/src/main/resources/jenkins/branch/OrganizationFolder/help-projectNamePattern.html b/src/main/resources/jenkins/branch/OrganizationFolder/help-projectNamePattern.html new file mode 100644 index 00000000..bbe6a0cd --- /dev/null +++ b/src/main/resources/jenkins/branch/OrganizationFolder/help-projectNamePattern.html @@ -0,0 +1,37 @@ +
+ Regular expression pattern used to extract project name suffix from Jenkinsfile path. + +

+ The pattern is applied to the Jenkinsfile path, and the first capture group + (if any) is appended to the base repository name to create the final project name. +

+ +

Default Pattern

+

+ .*?(-[^.]+).* +

+

+ This pattern captures everything after the last hyphen and before the final dot. +

+ +

Examples

+ + + + + + + + + + + + +
PatternJenkinsfileResulting Project
.*?(-[^.]+).*Jenkinsfile-winmyproject-win
.*?(-[^.]+).*Jenkinsfilemyproject
+ +

Pattern Tips

+
    +
  • The first capture group's content is used as the suffix
  • +
  • If no match is found, the original repository name is used
  • +
+
\ No newline at end of file diff --git a/src/test/java/jenkins/branch/OrganizationFolderMultiProjectTest.java b/src/test/java/jenkins/branch/OrganizationFolderMultiProjectTest.java new file mode 100644 index 00000000..f8aacef7 --- /dev/null +++ b/src/test/java/jenkins/branch/OrganizationFolderMultiProjectTest.java @@ -0,0 +1,213 @@ +/* + * The MIT License + * + * Copyright 2025 Jenkins Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.branch; + +import jenkins.scm.impl.SingleSCMNavigator; +import jenkins.scm.impl.SingleSCMSource; +import jenkins.scm.impl.mock.MockSCM; +import jenkins.scm.impl.mock.MockSCMController; +import jenkins.scm.impl.mock.MockSCMHead; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import java.util.Collections; +import static org.junit.Assert.*; + +public class OrganizationFolderMultiProjectTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + /** + * Tests that new configuration fields have correct default values. + * Verifies createMultipleProjects and projectNamePattern defaults. + */ + @Test + public void testMultiProjectConfigurationDefaults() throws Exception { + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "top"); + + // Test default values + assertFalse("createMultipleProjects should default to false", folder.isCreateMultipleProjects()); + assertEquals("Default project name pattern should be correct", ".*?(-[^.]+).*", folder.getProjectNamePattern()); + + // Test configuration round-trip + folder = r.configRoundtrip(folder); + + assertFalse("createMultipleProjects should still be false after round-trip", folder.isCreateMultipleProjects()); + assertEquals("Project name pattern should be preserved after round-trip", ".*?(-[^.]+).*", folder.getProjectNamePattern()); + } + + /** + * Tests setter methods for new configuration fields. + */ + @Test + public void testMultiProjectConfigurationSetters() throws Exception { + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "top"); + + // Test setting values + folder.setCreateMultipleProjects(true); + folder.setProjectNamePattern(".*?-(dev|prod).*"); + + assertTrue("createMultipleProjects should be true after setting", folder.isCreateMultipleProjects()); + assertEquals("Project name pattern should be updated", ".*?-(dev|prod).*", folder.getProjectNamePattern()); + + // Test null pattern handling + folder.setProjectNamePattern(null); + assertEquals("Null pattern should default to fallback", ".*?(-[^.]+).*", folder.getProjectNamePattern()); + } + + /** + * Tests configuration persistence through round-trip. + */ + @Test + public void testMultiProjectFormSubmission() throws Exception { + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "top"); + + // Test through configuration round-trip with modified values + folder.setCreateMultipleProjects(true); + folder.setProjectNamePattern(".*?-(test|staging).*"); + + OrganizationFolder configured = r.configRoundtrip(folder); + + assertTrue("createMultipleProjects should be true after configuration", configured.isCreateMultipleProjects()); + assertEquals("Project name pattern should be saved correctly", ".*?-(test|staging).*", configured.getProjectNamePattern()); + } + + /** + * Tests project name extraction functionality when multiple projects are enabled. + */ + @Test + public void testProjectNameExtractionWithMultipleProjects() throws Exception { + try (MockSCMController controller = MockSCMController.create()) { + // Create repository + controller.createRepository("myproject"); + + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "test-org"); + folder.setCreateMultipleProjects(true); + folder.setProjectNamePattern(".*?(-[^.]+).*"); + + // Add mock navigator and source + SingleSCMSource source = new SingleSCMSource("myproject-source", + new MockSCM(controller, "myproject", new MockSCMHead("master"), null)); + folder.getNavigators().add(new SingleSCMNavigator("myproject", Collections.singletonList(source))); + + // Trigger scan + folder.scheduleBuild(0); + r.waitUntilNoActivity(); + + // Verify that organization folder processes the scan + assertNotNull("Organization folder should process scan", folder.getComputation()); + + // Test configuration values + assertTrue("createMultipleProjects should be true", folder.isCreateMultipleProjects()); + assertEquals("Project name pattern should be set", ".*?(-[^.]+).*", folder.getProjectNamePattern()); + } + } + + /** + * Tests that configuration changes trigger rescans appropriately. + */ + @Test + public void testConfigurationChangeTriggersRescan() throws Exception { + try (MockSCMController controller = MockSCMController.create()) { + controller.createRepository("test-repo"); + + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "test-org"); + + // Add source for scanning + SingleSCMSource source = new SingleSCMSource("test-repo-source", + new MockSCM(controller, "test-repo", new MockSCMHead("master"), null)); + folder.getNavigators().add(new SingleSCMNavigator("test-repo", Collections.singletonList(source))); + + // Perform initial scan + folder.scheduleBuild(0); + r.waitUntilNoActivity(); + + // Change createMultipleProjects setting + folder.setCreateMultipleProjects(true); + folder.save(); + + // Change projectNamePattern setting + folder.setProjectNamePattern(".*?(-[^.]+).*"); + folder.save(); + + // Verify that folder is properly configured + assertTrue("createMultipleProjects should be true", folder.isCreateMultipleProjects()); + assertEquals("Project name pattern should be set", ".*?(-[^.]+).*", folder.getProjectNamePattern()); + + // Note: In a real test environment, we would verify that rescans are triggered + // but this requires more complex setup with listeners + } + } + + /** + * Tests backward compatibility - that existing configurations still work. + */ + @Test + public void testBackwardCompatibility() throws Exception { + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "legacy-folder"); + + // Ensure default values work for existing configurations + assertFalse("Legacy folders should have createMultipleProjects as false", folder.isCreateMultipleProjects()); + assertNotNull("Legacy folders should have default project name pattern", folder.getProjectNamePattern()); + + // Test that existing functionality still works + try (MockSCMController controller = MockSCMController.create()) { + controller.createRepository("legacy-project"); + + // Add source for scanning + SingleSCMSource source = new SingleSCMSource("legacy-project-source", + new MockSCM(controller, "legacy-project", new MockSCMHead("master"), null)); + folder.getNavigators().add(new SingleSCMNavigator("legacy-project", Collections.singletonList(source))); + + folder.scheduleBuild(0); + r.waitUntilNoActivity(); + + // Folder should work normally even without new features enabled + assertNotNull("Legacy folder should still function", folder.getComputation()); + } + } + + /** + * Tests edge cases for the project name pattern. + */ + @Test + public void testProjectNamePatternEdgeCases() throws Exception { + OrganizationFolder folder = r.jenkins.createProject(OrganizationFolder.class, "top"); + + // Test empty pattern + folder.setProjectNamePattern(""); + assertEquals("Empty pattern should be handled", "", folder.getProjectNamePattern()); + + // Test pattern with special regex characters + folder.setProjectNamePattern(".*?\\-(test|prod)\\-[0-9]+.*"); + assertEquals("Pattern with special characters should be preserved", + ".*?\\-(test|prod)\\-[0-9]+.*", folder.getProjectNamePattern()); + + // Test null pattern (should use default fallback) + folder.setProjectNamePattern(null); + assertEquals("Null pattern should use fallback", ".*?(-[^.]+).*", folder.getProjectNamePattern()); + } +} \ No newline at end of file