diff --git a/README.md b/README.md index 9021cbd..8e39884 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Build Status](https://travis-ci.org/ef-labs/stash-hook-mirror.png)](https://travis-ci.org/ef-labs/stash-hook-mirror) -#Bitbucket Server Repository Hook for Mirroring +# Bitbucket Server Repository Hook for Mirroring The following is a plugin for Atlassian Bitbucket Server to provide repository mirroring to a remote repository. diff --git a/src/main/java/com/englishtown/bitbucket/hook/MirrorRepositoryHook.java b/src/main/java/com/englishtown/bitbucket/hook/MirrorRepositoryHook.java index 7774456..152cd14 100644 --- a/src/main/java/com/englishtown/bitbucket/hook/MirrorRepositoryHook.java +++ b/src/main/java/com/englishtown/bitbucket/hook/MirrorRepositoryHook.java @@ -3,13 +3,8 @@ package com.englishtown.bitbucket.hook; import com.atlassian.bitbucket.hook.repository.AsyncPostReceiveRepositoryHook; import com.atlassian.bitbucket.hook.repository.RepositoryHookContext; import com.atlassian.bitbucket.i18n.I18nService; -import com.atlassian.bitbucket.repository.RefChange; -import com.atlassian.bitbucket.repository.Repository; -import com.atlassian.bitbucket.repository.RepositoryService; -import com.atlassian.bitbucket.scm.CommandExitHandler; -import com.atlassian.bitbucket.scm.DefaultCommandExitHandler; -import com.atlassian.bitbucket.scm.ScmCommandBuilder; -import com.atlassian.bitbucket.scm.ScmService; +import com.atlassian.bitbucket.repository.*; +import com.atlassian.bitbucket.scm.*; import com.atlassian.bitbucket.scm.git.command.GitScmCommandBuilder; import com.atlassian.bitbucket.setting.RepositorySettingsValidator; import com.atlassian.bitbucket.setting.Settings; @@ -26,6 +21,8 @@ import java.net.URISyntaxException; import java.util.*; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, RepositorySettingsValidator { @@ -34,12 +31,14 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep String username; String password; String suffix; + String branchesIncludePattern; } public static final String PLUGIN_SETTINGS_KEY = "com.englishtown.stash.hook.mirror"; static final String SETTING_MIRROR_REPO_URL = "mirrorRepoUrl"; static final String SETTING_USERNAME = "username"; static final String SETTING_PASSWORD = "password"; + static final String SETTING_BRANCHES_INCLUDE_PATTERN = "branchesIncludePattern"; static final int MAX_ATTEMPTS = 5; private final ScmService scmService; @@ -98,12 +97,12 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep List mirrorSettings = getMirrorSettings(context.getSettings()); for (MirrorSettings settings : mirrorSettings) { - runMirrorCommand(settings, context.getRepository()); + runMirrorCommand(settings, context.getRepository(), refChanges); } } - void runMirrorCommand(MirrorSettings settings, final Repository repository) { + void runMirrorCommand(MirrorSettings settings, final Repository repository, Collection refChanges) { if (repositoryService.isEmpty(repository)) { return; } @@ -129,18 +128,19 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep // Call push command with the prune flag and refspecs for heads and tags // Do not use the mirror flag as pull-request refs are included - String result = builder - .command("push") - .argument("--prune") // this deletes locally deleted branches - .argument(authenticatedUrl) - .argument("--force") // Canonical repository should always take precedence over mirror - .argument("+refs/heads/*:refs/heads/*") // Only mirror heads - .argument("+refs/tags/*:refs/tags/*") // and tags - .argument("+refs/notes/*:refs/notes/*") // and notes - .errorHandler(passwordHandler) - .exitHandler(passwordHandler) - .build(passwordHandler) - .call(); + builder.command("push") + .argument("--prune") // this deletes locally deleted branches + .argument("--atomic") // use an atomic transaction to have a consistent state + .argument(authenticatedUrl) + .argument("--force") // Canonical repository should always take precedence over mirror + .argument("+refs/tags/*:refs/tags/*") // and tags + .argument("+refs/notes/*:refs/notes/*"); // and notes + // add branch arguments + addBranchArguments(settings, refChanges, builder); + builder.errorHandler(passwordHandler) + .exitHandler(passwordHandler); + + String result = builder.build(passwordHandler).call(); logger.debug("MirrorRepositoryHook: postReceive completed with result '{}'.", result); @@ -207,7 +207,7 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep if (ok) { updateSettings(mirrorSettings, settings); for (MirrorSettings ms : mirrorSettings) { - runMirrorCommand(ms, repository); + runMirrorCommand(ms, repository, Collections.EMPTY_LIST); } } @@ -232,6 +232,7 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep ms.mirrorRepoUrl = settings.getString(SETTING_MIRROR_REPO_URL + suffix, ""); ms.username = settings.getString(SETTING_USERNAME + suffix, ""); ms.password = settings.getString(SETTING_PASSWORD + suffix, ""); + ms.branchesIncludePattern = (settings.getString(SETTING_BRANCHES_INCLUDE_PATTERN + suffix, "")); ms.suffix = String.valueOf(count++); results.add(ms); @@ -284,6 +285,15 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep ms.password = ms.username = ""; } + if (!ms.branchesIncludePattern.isEmpty()) { + try { + Pattern.compile(ms.branchesIncludePattern); + } catch (PatternSyntaxException e) { + result = false; + errors.addFieldError(SETTING_BRANCHES_INCLUDE_PATTERN + ms.suffix, "This is not a valid regular expression."); + } + } + return result; } @@ -296,6 +306,7 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep values.put(SETTING_MIRROR_REPO_URL + ms.suffix, ms.mirrorRepoUrl); values.put(SETTING_USERNAME + ms.suffix, ms.username); values.put(SETTING_PASSWORD + ms.suffix, (ms.password.isEmpty() ? ms.password : passwordEncryptor.encrypt(ms.password))); + values.put(SETTING_BRANCHES_INCLUDE_PATTERN + ms.suffix, ms.branchesIncludePattern); } // Unfortunately the settings are stored in an immutable map, so need to cheat with reflection @@ -319,4 +330,37 @@ public class MirrorRepositoryHook implements AsyncPostReceiveRepositoryHook, Rep return new PasswordHandler(password, new DefaultCommandExitHandler(i18nService)); } + private void addBranchArguments(MirrorSettings settings, Collection refChanges, CommandBuilder builder) { + Map branchModifyArguments = new HashMap<>(); + Map branchDeleteArguments = new HashMap<>(); + + // if an empty list of RefChanges was provided we assume this was caused by triggering after a config change. + // same if no branch pattern was specified we sync all branches + if(refChanges.isEmpty() || settings.branchesIncludePattern.isEmpty()) { + builder.argument("+refs/heads/*:refs/heads/*"); + } else { + for (RefChange refChange : refChanges) { + MinimalRef ref = refChange.getRef(); + String displayId = ref.getDisplayId(); + // branch operations + if (ref.getType().equals(StandardRefType.BRANCH)) { + if (displayId.matches(settings.branchesIncludePattern)) { + if (refChange.getType().equals(RefChangeType.DELETE) && !branchDeleteArguments.containsKey(displayId)) { + branchDeleteArguments.put(displayId, "+:refs/heads/" + displayId); + } else if ((refChange.getType().equals(RefChangeType.ADD) || refChange.getType().equals(RefChangeType.UPDATE)) && !branchModifyArguments.containsKey(displayId)) { + branchModifyArguments.put(displayId, "+refs/heads/" + displayId + ":refs/heads/" + displayId); + } + } + } + } + + for (String key : branchDeleteArguments.keySet()) { + builder.argument(branchDeleteArguments.get(key)); + } + + for (String key : branchModifyArguments.keySet()) { + builder.argument(branchModifyArguments.get(key)); + } + } + } } diff --git a/src/main/resources/i18n/stash-hook-mirror.properties b/src/main/resources/i18n/stash-hook-mirror.properties index 3227512..f66005e 100644 --- a/src/main/resources/i18n/stash-hook-mirror.properties +++ b/src/main/resources/i18n/stash-hook-mirror.properties @@ -10,3 +10,6 @@ mirror-repository-hook.username.description=The username to use for pushing to t mirror-repository-hook.password.label=Password mirror-repository-hook.password.description=The password to use for pushing to the mirror over http(s) + +mirror-repository-hook.branchesIncludePattern.label=Branches Include Pattern +mirror-repository-hook.branchesIncludePattern.description=Regex pattern for branches to include diff --git a/src/main/resources/static/mirror-repository-hook.soy b/src/main/resources/static/mirror-repository-hook.soy index c940ecd..46f7fe2 100644 --- a/src/main/resources/static/mirror-repository-hook.soy +++ b/src/main/resources/static/mirror-repository-hook.soy @@ -73,6 +73,16 @@ {param extraClasses: 'long' /} {param errorTexts: $errors ? $errors['password' + $index] : null /} {/call} + {call aui.form.textField} + {param id: 'branchesIncludePattern' + $index /} + {param value: $config['branchesIncludePattern' + $index] /} + {param labelContent} + {getText('mirror-repository-hook.branchesIncludePattern.label')} + {/param} + {param descriptionText: getText('mirror-repository-hook.branchesIncludePattern.description') /} + {param extraClasses: 'long' /} + {param errorTexts: $errors ? $errors['branchesIncludePattern' + $index] : null /} + {/call} {/template} diff --git a/src/test/java/com/englishtown/bitbucket/hook/MirrorRepositoryHookTest.java b/src/test/java/com/englishtown/bitbucket/hook/MirrorRepositoryHookTest.java index 1c8dda7..d07f950 100644 --- a/src/test/java/com/englishtown/bitbucket/hook/MirrorRepositoryHookTest.java +++ b/src/test/java/com/englishtown/bitbucket/hook/MirrorRepositoryHookTest.java @@ -2,8 +2,7 @@ package com.englishtown.bitbucket.hook; import com.atlassian.bitbucket.hook.repository.RepositoryHookContext; import com.atlassian.bitbucket.i18n.I18nService; -import com.atlassian.bitbucket.repository.Repository; -import com.atlassian.bitbucket.repository.RepositoryService; +import com.atlassian.bitbucket.repository.*; import com.atlassian.bitbucket.scm.CommandErrorHandler; import com.atlassian.bitbucket.scm.CommandExitHandler; import com.atlassian.bitbucket.scm.CommandOutputHandler; @@ -23,9 +22,7 @@ import org.mockito.Matchers; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -63,6 +60,7 @@ public class MirrorRepositoryHookTest { private final String username = "test-user"; private final String password = "test-password"; private final String repository = "https://test-user:test-password@bitbucket-mirror.englishtown.com/scm/test/test.git"; + private final String branchesIncludePattern ="master"; @Captor ArgumentCaptor argumentCaptor; @@ -96,6 +94,44 @@ public class MirrorRepositoryHookTest { verifyExecutor(); } + @Test + public void testPostReceiveWithBranchesPatternUpdateOperation() throws Exception { + when(passwordEncryptor.decrypt(anyString())).thenReturn(password); + + Repository repo = mock(Repository.class); + RefChange refChange = mock(RefChange.class); + MinimalRef ref = mock(MinimalRef.class); + + List refChanges = new ArrayList<>(); + refChanges.add(refChange); + when(refChange.getRef()).thenReturn(ref); + when(refChange.getType()).thenReturn(RefChangeType.UPDATE); + when(ref.getType()).thenReturn(StandardRefType.BRANCH); + when(ref.getDisplayId()).thenReturn("master"); + + hook.postReceive(buildContext(repo), refChanges); + verifyExecutorWithBranchesPatternUpdateOperation(); + } + + @Test + public void testPostReceiveWithBranchesPatternDeleteOperation() throws Exception { + when(passwordEncryptor.decrypt(anyString())).thenReturn(password); + + Repository repo = mock(Repository.class); + RefChange refChange = mock(RefChange.class); + MinimalRef ref = mock(MinimalRef.class); + + List refChanges = new ArrayList<>(); + refChanges.add(refChange); + when(refChange.getRef()).thenReturn(ref); + when(refChange.getType()).thenReturn(RefChangeType.DELETE); + when(ref.getType()).thenReturn(StandardRefType.BRANCH); + when(ref.getDisplayId()).thenReturn("master"); + + hook.postReceive(buildContext(repo), refChanges); + verifyExecutorWithBranchesPatternDeleteOperation(); + } + @Test public void testEmptyRepositoriesNotMirrored() { Repository repo = mock(Repository.class); @@ -116,7 +152,7 @@ public class MirrorRepositoryHookTest { ms.mirrorRepoUrl = mirrorRepoUrlHttp; ms.username = username; ms.password = password; - hook.runMirrorCommand(ms, mock(Repository.class)); + hook.runMirrorCommand(ms, mock(Repository.class), Collections.emptyList()); verify(executor).submit(argumentCaptor.capture()); Runnable runnable = argumentCaptor.getValue(); @@ -154,6 +190,7 @@ public class MirrorRepositoryHookTest { verify(builder, times(1)).command(eq("push")); verify(builder, times(1)).argument(eq("--prune")); + verify(builder, times(1)).argument(eq("--atomic")); verify(builder, times(1)).argument(eq(repository)); verify(builder, times(1)).argument(eq("+refs/heads/*:refs/heads/*")); verify(builder, times(1)).argument(eq("+refs/tags/*:refs/tags/*")); @@ -161,6 +198,38 @@ public class MirrorRepositoryHookTest { } + private void verifyExecutorWithBranchesPatternUpdateOperation() throws Exception { + + verify(executor).submit(argumentCaptor.capture()); + Runnable runnable = argumentCaptor.getValue(); + runnable.run(); + + verify(builder, times(1)).command(eq("push")); + verify(builder, times(1)).argument(eq("--prune")); + verify(builder, times(1)).argument(eq("--atomic")); + verify(builder, times(1)).argument(eq(repository)); + verify(builder, times(1)).argument(eq("+refs/heads/master:refs/heads/master")); + verify(builder, times(1)).argument(eq("+refs/tags/*:refs/tags/*")); + verify(cmd, times(1)).call(); + + } + + private void verifyExecutorWithBranchesPatternDeleteOperation() throws Exception { + + verify(executor).submit(argumentCaptor.capture()); + Runnable runnable = argumentCaptor.getValue(); + runnable.run(); + + verify(builder, times(1)).command(eq("push")); + verify(builder, times(1)).argument(eq("--prune")); + verify(builder, times(1)).argument(eq("--atomic")); + verify(builder, times(1)).argument(eq(repository)); + verify(builder, times(1)).argument(eq("+:refs/heads/master")); + verify(builder, times(1)).argument(eq("+refs/tags/*:refs/tags/*")); + verify(cmd, times(1)).call(); + + } + @Test public void testGetAuthenticatedUrl() throws Exception { @@ -201,6 +270,11 @@ public class MirrorRepositoryHookTest { .thenReturn("") .thenReturn(password); + when(settings.getString(eq(MirrorRepositoryHook.SETTING_BRANCHES_INCLUDE_PATTERN + "0"), eq(""))) + .thenReturn("??") + .thenReturn("master") + .thenReturn(""); + Repository repo = mock(Repository.class); SettingsValidationErrors errors; @@ -215,6 +289,7 @@ public class MirrorRepositoryHookTest { verify(errors).addFieldError(eq(MirrorRepositoryHook.SETTING_MIRROR_REPO_URL + "0"), anyString()); verify(errors, never()).addFieldError(eq(MirrorRepositoryHook.SETTING_USERNAME + "0"), anyString()); verify(errors, never()).addFieldError(eq(MirrorRepositoryHook.SETTING_PASSWORD + "0"), anyString()); + verify(errors).addFieldError(eq(MirrorRepositoryHook.SETTING_BRANCHES_INCLUDE_PATTERN + "0"), anyString()); errors = mock(SettingsValidationErrors.class); hook.validate(settings, errors, repo); @@ -272,6 +347,7 @@ public class MirrorRepositoryHookTest { when(settings.getString(eq(MirrorRepositoryHook.SETTING_MIRROR_REPO_URL), eq(""))).thenReturn(mirrorRepoUrlHttp); when(settings.getString(eq(MirrorRepositoryHook.SETTING_USERNAME), eq(""))).thenReturn(username); when(settings.getString(eq(MirrorRepositoryHook.SETTING_PASSWORD), eq(""))).thenReturn(password); + when(settings.getString(eq(MirrorRepositoryHook.SETTING_BRANCHES_INCLUDE_PATTERN), eq(""))).thenReturn(branchesIncludePattern); return settings; } }