This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch feature/SLING-13253-update-dist in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 69d004e9412051fd9570993ec8595604a8e84114 Author: Roy Teeuwen <[email protected]> AuthorDate: Sat Jul 4 15:04:17 2026 +0200 SLING-13253 - release update-dist command using embedded SVNKit Adds 'release update-dist' to move release artifacts from dist/dev to dist/release on dist.apache.org and remove the previous release, using the pure-Java SVNKit client over https instead of shelling out to svn/svnmucc. SVNKit is embedded into the sling-cli bundle via bnd (its OSGi bundles are published only to the Eclipse P2 update site, not Maven Central); only the jars needed for https/DAV url operations are embedded, and the svn+ssh:// stack is marked optional so the bundle resolves in Felix. Part of splitting PR #28 into per-command changes; builds on the list PR. --- bnd.bnd | 28 +- pom.xml | 39 +++ .../sling/cli/impl/release/UpdateDistCommand.java | 265 +++++++++++++++++ .../cli/impl/release/UpdateDistCommandTest.java | 328 +++++++++++++++++++++ 4 files changed, 659 insertions(+), 1 deletion(-) diff --git a/bnd.bnd b/bnd.bnd index 78e45e3..658fa39 100644 --- a/bnd.bnd +++ b/bnd.bnd @@ -1 +1,27 @@ -Import-Package: !org.fusesource.jansi, * +# Embed SVNKit and the minimal transitive jars it needs for https/DAV url operations into this +# bundle (see pom.xml). Everything lands on a single classloader so SVNKit's internal reflection +# and service lookups resolve without cross-bundle wiring. +-includeresource: \ + @svnkit-[0-9.]*.jar, \ + @sequence-library-[0-9.]*.jar, \ + @sqljet-[0-9.]*.jar, \ + @antlr-runtime-[0-9.]*.jar + +# The svn+ssh:// / native / server-side packages are intentionally not embedded; SVNKit references +# them but never loads them on the https url code path, so import them optionally to keep the bundle +# resolvable in the Felix runtime. +Import-Package: \ + !org.fusesource.jansi, \ + org.apache.sshd.*;resolution:=optional, \ + com.sun.jna.*;resolution:=optional, \ + com.trilead.*;resolution:=optional, \ + com.jcraft.*;resolution:=optional, \ + net.i2p.crypto.*;resolution:=optional, \ + net.jpountz.*;resolution:=optional, \ + javax.servlet.*;resolution:=optional, \ + org.tigris.subversion.javahl.*;resolution:=optional, \ + org.antlr.stringtemplate.*;resolution:=optional, \ + jcifs.*;resolution:=optional, \ + de.rbri.*;resolution:=optional, \ + javax.security.sasl;resolution:=optional, \ + * diff --git a/pom.xml b/pom.xml index 1a4c18c..9cba820 100644 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,45 @@ <version>72.1</version> <scope>provided</scope> </dependency> + <!-- + SVNKit is the only pure-Java Subversion client; its OSGi bundles are published only to the + Eclipse P2 update site, not to Maven Central. We therefore embed it (and the minimal set of + transitive jars needed for https/DAV url operations) into the sling-cli bundle via bnd. The + svn+ssh:// stack (sshd, jna, trilead, jsch-agentproxy, eddsa, lz4) is excluded and its + packages are marked optional in bnd.bnd. + --> + <dependency> + <groupId>org.tmatesoft.svnkit</groupId> + <artifactId>svnkit</artifactId> + <version>1.10.11</version> + <scope>provided</scope> + <exclusions> + <exclusion> + <groupId>org.apache.sshd</groupId> + <artifactId>*</artifactId> + </exclusion> + <exclusion> + <groupId>net.i2p.crypto</groupId> + <artifactId>*</artifactId> + </exclusion> + <exclusion> + <groupId>net.java.dev.jna</groupId> + <artifactId>*</artifactId> + </exclusion> + <exclusion> + <groupId>com.trilead</groupId> + <artifactId>*</artifactId> + </exclusion> + <exclusion> + <groupId>com.jcraft</groupId> + <artifactId>*</artifactId> + </exclusion> + <exclusion> + <groupId>org.lz4</groupId> + <artifactId>*</artifactId> + </exclusion> + </exclusions> + </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java new file mode 100644 index 0000000..4ff653d --- /dev/null +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.cli.impl.release; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tmatesoft.svn.core.SVNDirEntry; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.auth.BasicAuthenticationManager; +import org.tmatesoft.svn.core.internal.io.dav.DAVRepositoryFactory; +import org.tmatesoft.svn.core.io.SVNRepository; +import org.tmatesoft.svn.core.io.SVNRepositoryFactory; +import org.tmatesoft.svn.core.wc.SVNRevision; +import org.tmatesoft.svn.core.wc2.SvnCopySource; +import org.tmatesoft.svn.core.wc2.SvnOperationFactory; +import org.tmatesoft.svn.core.wc2.SvnRemoteCopy; +import org.tmatesoft.svn.core.wc2.SvnRemoteDelete; +import org.tmatesoft.svn.core.wc2.SvnTarget; +import picocli.CommandLine; + +/** + * Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the + * previous release, using SVNKit's pure-Java Subversion client over https. + * Requires PMC membership to commit to dist.apache.org. + */ +@Component( + service = Command.class, + property = { + Command.PROPERTY_NAME_COMMAND_GROUP + "=" + UpdateDistCommand.GROUP, + Command.PROPERTY_NAME_COMMAND_NAME + "=" + UpdateDistCommand.NAME + }) [email protected]( + name = UpdateDistCommand.NAME, + description = "Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the" + + " previous release. Requires PMC membership.", + subcommands = CommandLine.HelpCommand.class) +public class UpdateDistCommand implements Command { + + static final String GROUP = "release"; + static final String NAME = "update-dist"; + + static final String DIST_DEV_URL = "https://dist.apache.org/repos/dist/dev/sling/"; + static final String DIST_RELEASE_URL = "https://dist.apache.org/repos/dist/release/sling/"; + + static { + // register the http(s):// DAV repository factory used by all SVNKit operations below + DAVRepositoryFactory.setup(); + } + + private static final Logger LOGGER = LoggerFactory.getLogger(UpdateDistCommand.class); + + @CommandLine.Option( + names = {"-r", "--repository"}, + description = "Nexus staging repository id", + required = true) + private Integer repositoryId; + + @CommandLine.Option( + names = {"--previous-version"}, + description = "Previous release version to remove from dist/release (e.g. 1.0.0)." + + " Optional: if omitted, all older versions currently in dist/release are removed.") + private String previousVersion; + + @CommandLine.Mixin + private ReusableCLIOptions reusableCLIOptions; + + @Reference + private RepositoryService repositoryService; + + @Reference + private CredentialsService credentialsService; + + @Override + public Integer call() { + try { + StagingRepository repository = repositoryService.find(repositoryId); + Set<Artifact> artifacts = repositoryService.getArtifacts(repository); + Artifact primary = artifacts.stream() + .filter(a -> "pom".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No POM artifact found in staging repository")); + String artifactId = primary.getArtifactId(); + String newVersion = primary.getVersion(); + + List<String> newFiles = listDistFiles(DIST_DEV_URL, artifactId + "-" + newVersion); + List<String> oldFiles = listPreviousReleaseFiles(artifactId, newVersion, previousVersion); + + if (newFiles.isEmpty()) { + LOGGER.warn("No files found in {} matching {}-{}", DIST_DEV_URL, artifactId, newVersion); + LOGGER.warn("Ensure 'mvn release:perform' has staged source-release artifacts to dist/dev."); + return CommandLine.ExitCode.USAGE; + } + + switch (reusableCLIOptions.executionMode) { + case DRY_RUN: + LOGGER.info("Would move {} file(s) from dist/dev to dist/release:", newFiles.size()); + newFiles.forEach(f -> LOGGER.info(" mv {} -> {}", DIST_DEV_URL + f, DIST_RELEASE_URL + f)); + if (!oldFiles.isEmpty()) { + LOGGER.info("Would remove {} old file(s) from dist/release:", oldFiles.size()); + oldFiles.forEach(f -> LOGGER.info(" rm {}", DIST_RELEASE_URL + f)); + } + break; + case INTERACTIVE: + String question = String.format( + "Move %d file(s) for %s %s to dist/release and remove %d older file(s) for %s?", + newFiles.size(), artifactId, newVersion, oldFiles.size(), artifactId); + InputOption answer = UserInput.yesNo(question, InputOption.YES); + if (InputOption.YES.equals(answer)) { + commitDistUpdate( + artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials()); + } else { + LOGGER.info("Aborted."); + } + break; + case AUTO: + commitDistUpdate( + artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials()); + break; + } + } catch (IOException e) { + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } + return CommandLine.ExitCode.OK; + } + + /** + * Moves {@code newFiles} from {@code dist/dev} to {@code dist/release} and removes {@code oldFiles} + * from {@code dist/release}, using SVNKit over https. The move (copy + delete of the sources) is a + * single atomic commit; the removal of superseded files is a second commit. Ordering is deliberate: + * the new release is fully published before the previous one is removed, so an interrupted run never + * leaves {@code dist/release} without a released version. + */ + static void commitDistUpdate( + String artifactId, String newVersion, List<String> newFiles, List<String> oldFiles, Credentials credentials) + throws IOException { + Logger logger = LoggerFactory.getLogger(UpdateDistCommand.class); + SvnOperationFactory svnOperationFactory = new SvnOperationFactory(); + svnOperationFactory.setAuthenticationManager( + new BasicAuthenticationManager(credentials.getUsername(), credentials.getPassword())); + try { + SvnRemoteCopy move = svnOperationFactory.createRemoteCopy(); + move.setMove(true); + move.setFailWhenDstExists(false); + move.setCommitMessage("Release " + artifactId + " " + newVersion); + move.setSingleTarget(SvnTarget.fromURL(SVNURL.parseURIEncoded(stripTrailingSlash(DIST_RELEASE_URL)))); + for (String file : newFiles) { + move.addCopySource(SvnCopySource.create( + SvnTarget.fromURL(SVNURL.parseURIEncoded(DIST_DEV_URL + file)), SVNRevision.HEAD)); + } + logger.info("Moving {} file(s) from dist/dev to dist/release...", newFiles.size()); + move.run(); + + if (!oldFiles.isEmpty()) { + SvnRemoteDelete delete = svnOperationFactory.createRemoteDelete(); + delete.setCommitMessage("Remove superseded release of " + artifactId); + for (String file : oldFiles) { + delete.addTarget(SvnTarget.fromURL(SVNURL.parseURIEncoded(DIST_RELEASE_URL + file))); + } + logger.info("Removing {} superseded file(s) from dist/release...", oldFiles.size()); + delete.run(); + } + logger.info("Done. dist.apache.org has been updated."); + } catch (SVNException e) { + throw new IOException("Failed to update dist.apache.org", e); + } finally { + svnOperationFactory.dispose(); + } + } + + /** + * Determines which files to remove from {@code dist/release} when publishing {@code newVersion} + * of {@code artifactId}. When {@code explicitPreviousVersion} is given, only that version's files + * are returned; otherwise every older version currently present in {@code dist/release} for this + * artifact is returned (the release directory holds only the latest release per ASF policy). + */ + static List<String> listPreviousReleaseFiles(String artifactId, String newVersion, String explicitPreviousVersion) + throws IOException { + if (explicitPreviousVersion != null && !explicitPreviousVersion.isBlank()) { + return listDistFiles(DIST_RELEASE_URL, artifactId + "-" + explicitPreviousVersion); + } + return listDistFiles(DIST_RELEASE_URL, artifactId + "-").stream() + // keep only versioned files for this exact artifact (a numeric version component + // right after the prefix excludes sibling artifacts such as artifactId-extra-...) + .filter(f -> isVersionedArtifactFile(f, artifactId)) + // never remove the version we are about to publish + .filter(f -> !belongsToVersion(f, artifactId, newVersion)) + .toList(); + } + + private static boolean isVersionedArtifactFile(String fileName, String artifactId) { + String prefix = artifactId + "-"; + return fileName.length() > prefix.length() + && fileName.startsWith(prefix) + && Character.isDigit(fileName.charAt(prefix.length())); + } + + private static boolean belongsToVersion(String fileName, String artifactId, String version) { + String prefix = artifactId + "-" + version; + if (!fileName.startsWith(prefix)) { + return false; + } + // the version must be the whole name, or be followed by an extension dot or a classifier dash; + // this avoids matching version 1.0.14 against the longer 1.0.140 + if (fileName.length() == prefix.length()) { + return true; + } + char next = fileName.charAt(prefix.length()); + return next == '.' || next == '-'; + } + + static List<String> listDistFiles(String baseUrl, String prefix) throws IOException { + List<String> files = new ArrayList<>(); + try { + SVNRepository repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(baseUrl)); + long revision = repository.getLatestRevision(); + Collection<SVNDirEntry> entries = new ArrayList<>(); + repository.getDir("", revision, null, entries); + entries.stream() + .map(SVNDirEntry::getName) + .filter(name -> name.startsWith(prefix)) + .forEach(files::add); + } catch (SVNException e) { + throw new IOException("Failed to list " + baseUrl, e); + } + return files; + } + + private static String stripTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java new file mode 100644 index 0000000..2caf612 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java @@ -0,0 +1,328 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.cli.impl.release; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class UpdateDistCommandTest { + + private static final String ARTIFACT = "org.apache.sling.feature.launcher"; + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(UpdateDistCommand.class); + + // ---- auto-deduce of the previous release files (listPreviousReleaseFiles) ---- + + @Test + public void testAutoDeducePreviousFilesExcludesNewVersionAndSiblings() throws Exception { + List<String> releaseDir = List.of( + ARTIFACT + "-1.3.4.pom", + ARTIFACT + "-1.3.4.pom.asc", + ARTIFACT + "-1.3.4-source-release.zip", + ARTIFACT + "-1.3.4-source-release.zip.asc", + ARTIFACT + "-1.3.6.pom", // the new version - must be kept (not removed) + ARTIFACT + "-1.3.6.pom.asc", + ARTIFACT + "-extra-1.0.0.pom" // a sibling artifact - must be ignored + ); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(releaseDir); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", null); + + assertEquals(4, old.size()); + assertTrue(old.contains(ARTIFACT + "-1.3.4.pom")); + assertTrue(old.contains(ARTIFACT + "-1.3.4-source-release.zip")); + // the version being published is never removed + assertFalse(old.contains(ARTIFACT + "-1.3.6.pom")); + assertFalse(old.contains(ARTIFACT + "-1.3.6.pom.asc")); + // sibling artifact with a non-numeric component is ignored + assertFalse(old.contains(ARTIFACT + "-extra-1.0.0.pom")); + } + } + + @Test + public void testAutoDeduceDoesNotConfuseVersionPrefixes() throws Exception { + // publishing 1.0.14 must not treat 1.0.140 as the same version + List<String> releaseDir = List.of(ARTIFACT + "-1.0.140.pom", ARTIFACT + "-1.0.14.pom"); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(releaseDir); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.0.14", null); + + // 1.0.140 is a different version and is removed; 1.0.14 (being published) is kept + assertEquals(List.of(ARTIFACT + "-1.0.140.pom"), old); + } + } + + @Test + public void testExplicitPreviousVersionWins() throws Exception { + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles( + eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-1.3.4"))) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", "1.3.4"); + + assertEquals(List.of(ARTIFACT + "-1.3.4.pom"), old); + // when an explicit version is given, the directory is not enumerated with the bare prefix + dist.verify( + () -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-")), + never()); + } + } + + @Test + public void testAutoDeduceKeepsNewVersionWithClassifierAndExtension() throws Exception { + // files for the version being published (with both an extension '.' and a classifier '-' right + // after the version) must be kept, exercising belongsToVersion's trailing-character check + List<String> releaseDir = List.of( + ARTIFACT + "-1.3.6", // exact match: filename equals the version prefix with no extension + ARTIFACT + "-1.3.6.pom", + ARTIFACT + "-1.3.6-source-release.zip", + ARTIFACT + "-1.3.4.pom"); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(releaseDir); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", null); + + assertEquals(List.of(ARTIFACT + "-1.3.4.pom"), old); + assertFalse(old.contains(ARTIFACT + "-1.3.6")); + assertFalse(old.contains(ARTIFACT + "-1.3.6.pom")); + assertFalse(old.contains(ARTIFACT + "-1.3.6-source-release.zip")); + } + } + + // ---- full command flow ---- + + @Test + public void testDryRunDescribesMoveAndRemoveWithoutCommitting() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + + Command command = createCommand(ExecutionMode.DRY_RUN, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + assertTrue(logCapture.containsMessage("Would move 1 file(s) from dist/dev to dist/release:")); + assertTrue(logCapture.containsMessage("Would remove 1 old file(s) from dist/release:")); + dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never()); + } + } + + @Test + public void testAutoCommitsViaSvnMucc() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + // do not actually shell out to svnmucc + dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> null); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any())); + } + } + + @Test + public void testNoNewFilesReturnsUsage() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of()); + // the release directory listing is computed before the empty-new-files check; stub it so + // the real `svn` is never invoked + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of()); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never()); + } + } + + @Test + public void testInteractiveYesCommitsViaSvnMucc() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS); + MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> null); + userInput + .when(() -> UserInput.yesNo(anyString(), eq(InputOption.YES))) + .thenReturn(InputOption.YES); + + Command command = createCommand(ExecutionMode.INTERACTIVE, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any())); + } + } + + @Test + public void testInteractiveNoAborts() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS); + MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + userInput + .when(() -> UserInput.yesNo(anyString(), eq(InputOption.YES))) + .thenReturn(InputOption.NO); + + Command command = createCommand(ExecutionMode.INTERACTIVE, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + assertTrue(logCapture.containsMessage("Aborted.")); + dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never()); + } + } + + @Test + public void testExplicitPreviousVersionFullFlow() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listDistFiles( + eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-1.3.4"))) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> null); + + Command command = createCommand(ExecutionMode.AUTO, "1.3.4"); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any())); + } + } + + @Test + public void testIOExceptionReturnsSoftware() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listDistFiles(any(), anyString())).thenThrow(new IOException("svn boom")); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed executing command")); + } + } + + @Test + public void testNoPomArtifactThrows() throws Exception { + StagingRepository repository = mock(StagingRepository.class); + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(repository); + // a staging repository without a POM artifact triggers the orElseThrow guard + Artifact jar = new Artifact(repository, "org.apache.sling", ARTIFACT, "1.3.6", null, "jar"); + when(repositoryService.getArtifacts(repository)).thenReturn(Set.of(jar)); + CredentialsService credentialsService = mock(CredentialsService.class); + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(CredentialsService.class, credentialsService); + + Command command = createCommand(ExecutionMode.AUTO, null); + try { + command.call(); + org.junit.Assert.fail("Expected an IllegalStateException when no POM artifact is present."); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("No POM artifact")); + } + } + + private void prepareRepositoryService() throws Exception { + StagingRepository repository = mock(StagingRepository.class); + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(repository); + Artifact pom = new Artifact(repository, "org.apache.sling", ARTIFACT, "1.3.6", null, "pom"); + when(repositoryService.getArtifacts(repository)).thenReturn(Set.of(pom)); + + CredentialsService credentialsService = mock(CredentialsService.class); + when(credentialsService.getAsfCredentials()).thenReturn(new Credentials("johndoe", "secret")); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(CredentialsService.class, credentialsService); + } + + private Command createCommand(ExecutionMode executionMode, String previousVersion) throws IllegalAccessException { + UpdateDistCommand updateDistCommand = spy(new UpdateDistCommand()); + FieldUtils.writeField(updateDistCommand, "repositoryId", 123, true); + FieldUtils.writeField(updateDistCommand, "previousVersion", previousVersion, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(updateDistCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(updateDistCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the UpdateDistCommand from the mocked OSGi environment.", + result instanceof UpdateDistCommand); + return result; + } +}
