This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch feature/SLING-13253-act-by-name in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 343b26e06629d1fcf6016a702fdec800232fe646 Author: Roy Teeuwen <[email protected]> AuthorDate: Wed Jul 8 20:49:53 2026 +0200 SLING-13253 - release: act on a release by name Lets the post-vote release commands (create-jira-version, release-jira-version, update-local-site, update-reporter) accept a release by --release name in addition to a staging repository id, and adds tests for the JIRA-version and verify commands. A CommandProcessor test guards that a multi-word release name passed as a quoted argument is parsed intact. Split out of the finalize PR (#34) since these changes are independent of the finalize command and can be merged on their own. --- .../cli/impl/release/CreateJiraVersionCommand.java | 29 +-- .../impl/release/ReleaseJiraVersionCommand.java | 29 ++- .../cli/impl/release/UpdateLocalSiteCommand.java | 28 ++- .../cli/impl/release/UpdateReporterCommand.java | 28 ++- .../sling/cli/impl/CommandProcessorTest.java | 38 ++++ .../impl/release/CreateJiraVersionCommandTest.java | 217 +++++++++++++++++++++ .../release/ReleaseJiraVersionCommandTest.java | 152 +++++++++++++++ .../impl/release/UpdateLocalSiteCommandTest.java | 154 +++++++++++++++ .../impl/release/UpdateReporterCommandTest.java | 51 +++++ .../impl/release/VerifyReleasesCommandTest.java | 127 ++++++++++++ 10 files changed, 827 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java index 226e01a..41170dc 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java @@ -30,7 +30,6 @@ import org.apache.sling.cli.impl.jira.Issue; import org.apache.sling.cli.impl.jira.Version; import org.apache.sling.cli.impl.jira.VersionClient; 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; @@ -56,8 +55,7 @@ public class CreateJiraVersionCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; @Reference @@ -67,9 +65,10 @@ public class CreateJiraVersionCommand implements Command { private VersionClient versionClient; @CommandLine.Option( - names = {"--version-name"}, - description = "Jira version name to use", - required = false) + names = {"--release", "--version-name"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") private String jiraVersionName; @CommandLine.Mixin @@ -80,7 +79,12 @@ public class CreateJiraVersionCommand implements Command { @Override public Integer call() { try { - for (Release release : releases()) { + Collection<Release> releases = releases(); + if (releases.isEmpty()) { + logger.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } + for (Release release : releases) { Version version = versionClient.find(release); logger.info("Found {}.", version); Version successorVersion = versionClient.findSuccessorVersion(release); @@ -145,9 +149,12 @@ public class CreateJiraVersionCommand implements Command { } private Collection<Release> releases() throws IOException { - if (jiraVersionName != null) return Release.fromString(jiraVersionName); - - StagingRepository repo = repositoryService.find(repositoryId); - return repositoryService.getReleases(repo); + if (jiraVersionName != null && !jiraVersionName.isBlank()) { + return Release.fromString(jiraVersionName); + } + if (repositoryId == null) { + return List.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); } } diff --git a/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java index 5aa9a1c..5eaa893 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java @@ -18,6 +18,7 @@ */ package org.apache.sling.cli.impl.release; +import java.io.IOException; import java.util.List; import java.util.Set; @@ -28,7 +29,6 @@ import org.apache.sling.cli.impl.UserInput; import org.apache.sling.cli.impl.jira.Issue; import org.apache.sling.cli.impl.jira.VersionClient; 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; @@ -58,10 +58,16 @@ public class ReleaseJiraVersionCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; + @CommandLine.Option( + names = {"--release"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") + private String releaseName; + @Reference private RepositoryService repositoryService; @@ -71,11 +77,24 @@ public class ReleaseJiraVersionCommand implements Command { @CommandLine.Mixin private ReusableCLIOptions reusableCLIOptions; + private Set<Release> resolveReleases() throws IOException { + if (releaseName != null && !releaseName.isBlank()) { + return Set.copyOf(Release.fromString(releaseName)); + } + if (repositoryId == null) { + return Set.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); + } + @Override public Integer call() { try { - StagingRepository repo = repositoryService.find(repositoryId); - Set<Release> releases = repositoryService.getReleases(repo); + Set<Release> releases = resolveReleases(); + if (releases.isEmpty()) { + LOGGER.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } ExecutionMode executionMode = reusableCLIOptions.executionMode; LOGGER.info( "The following Jira versions {} be released:{}", diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java index b9a3e6e..e92de4c 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java @@ -28,7 +28,6 @@ import java.util.Set; import org.apache.sling.cli.impl.Command; import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; import org.apache.sling.cli.impl.nexus.RepositoryService; -import org.apache.sling.cli.impl.nexus.StagingRepository; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand.ResetType; import org.eclipse.jgit.api.errors.GitAPIException; @@ -63,18 +62,37 @@ public class UpdateLocalSiteCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; + @CommandLine.Option( + names = {"--release"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") + private String releaseName; + + private Set<Release> resolveReleases() throws IOException { + if (releaseName != null && !releaseName.isBlank()) { + return Set.copyOf(Release.fromString(releaseName)); + } + if (repositoryId == null) { + return Set.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); + } + @Override public Integer call() { try { ensureRepo(); try (Git git = Git.open(new File(GIT_CHECKOUT))) { - StagingRepository repository = repositoryService.find(repositoryId); - Set<Release> releases = repositoryService.getReleases(repository); + Set<Release> releases = resolveReleases(); + if (releases.isEmpty()) { + logger.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } JBakeContentUpdater updater = new JBakeContentUpdater(); diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java index 983618c..66e446f 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java @@ -37,7 +37,6 @@ import org.apache.sling.cli.impl.InputOption; import org.apache.sling.cli.impl.UserInput; import org.apache.sling.cli.impl.http.HttpClientFactory; 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; @@ -69,18 +68,37 @@ public class UpdateReporterCommand implements Command { @CommandLine.Option( names = {"-r", "--repository"}, - description = "Nexus repository id", - required = true) + description = "Nexus staging repository id to derive the release(s) from") private Integer repositoryId; + @CommandLine.Option( + names = {"--release"}, + description = "Release name(s) to act on, e.g. \"Apache Sling Foo 1.2.0\" (comma-separated for multiple)." + + " Use instead of --repository when the staging repository no longer exists," + + " e.g. after the release has been promoted.") + private String releaseName; + @CommandLine.Mixin private ReusableCLIOptions reusableCLIOptions; + private Set<Release> resolveReleases() throws IOException { + if (releaseName != null && !releaseName.isBlank()) { + return Set.copyOf(Release.fromString(releaseName)); + } + if (repositoryId == null) { + return Set.of(); + } + return repositoryService.getReleases(repositoryService.find(repositoryId)); + } + @Override public Integer call() { try { - StagingRepository repository = repositoryService.find(repositoryId); - Set<Release> releases = repositoryService.getReleases(repository); + Set<Release> releases = resolveReleases(); + if (releases.isEmpty()) { + LOGGER.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } String releaseReleases = releases.size() > 1 ? "releases" : "release"; switch (reusableCLIOptions.executionMode) { case DRY_RUN: diff --git a/src/test/java/org/apache/sling/cli/impl/CommandProcessorTest.java b/src/test/java/org/apache/sling/cli/impl/CommandProcessorTest.java index bf31c16..4e07f4c 100644 --- a/src/test/java/org/apache/sling/cli/impl/CommandProcessorTest.java +++ b/src/test/java/org/apache/sling/cli/impl/CommandProcessorTest.java @@ -99,4 +99,42 @@ public class CommandProcessorTest { assertThat(cmd.repositoryId, equalTo(24)); assertThat(cmd.description, equalTo("Test 'description'")); } + + /** + * A multi-word option value passed as a separate, quoted argument (e.g. {@code --release "Scripting + * Core 2.1.4"}) must survive intact: the shell passes it as one argument, run.sh writes it on its + * own line, and the newline-based arg parsing must not split it on spaces. + */ + @Test + public void multiWordOptionValuePassedAsSeparateArgument() { + CommandProcessor commandProcessor = new CommandProcessor() { + @Override + protected String getArgLine() { + return "release\n" + "mock\n" + "-r\n" + "24\n" + "--description\n" + "Scripting Core 2.1.4"; + } + + @Override + protected void stopFramework() { + /* ignored */ + } + + @Override + protected void terminateExecution(int commandExitCode) { + /* ignored */ + } + }; + MockCommand cmd = new MockCommand(); + commandProcessor.bindCommand( + cmd, + Map.of( + Command.PROPERTY_NAME_COMMAND_GROUP, + MockCommand.COMMAND_GROUP, + Command.PROPERTY_NAME_COMMAND_NAME, + MockCommand.COMMAND_NAME)); + + commandProcessor.runCommand(); + + assertThat(cmd.invocationCount, equalTo(1)); + assertThat(cmd.description, equalTo("Scripting Core 2.1.4")); + } } diff --git a/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java new file mode 100644 index 0000000..3e23847 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java @@ -0,0 +1,217 @@ +/* + * 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.util.List; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +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.jira.Issue; +import org.apache.sling.cli.impl.jira.Version; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.RepositoryService; +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class CreateJiraVersionCommandTest { + + private static final String VERSION_NAME = "Apache Sling CLI Test 1.0.0"; + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(CreateJiraVersionCommand.class); + + private VersionClient versionClient; + + private void prepare() { + versionClient = mock(VersionClient.class); + osgiContext.registerService(VersionClient.class, versionClient); + osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); + } + + @Test + public void testDryRunCreatesNothing() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(null); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of()); + + Command command = createCommand(ExecutionMode.DRY_RUN, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient, never()).create(anyString()); + assertTrue(logCapture.containsMessage("would be created")); + } + + @Test + public void testAutoCreatesSuccessor() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(null, successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of()); + + Command command = createCommand(ExecutionMode.AUTO, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient, times(1)).create(anyString()); + } + + @Test + public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { + prepare(); + Command command = createCommandWithoutRepository(ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); + verifyNoInteractions(versionClient); + } + + @Test + public void testAutoMovesUnresolvedIssues() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + Issue issue = mock(Issue.class); + when(issue.getKey()).thenReturn("SLING-123"); + when(issue.getSummary()).thenReturn("Some bug"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + Command command = createCommand(ExecutionMode.AUTO, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(versionClient, never()).create(anyString()); + verify(versionClient, times(1)).moveIssuesToNewVersion(any(), any(), any()); + } + + @Test + public void testInteractiveCreatesAndMovesOnYes() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + Issue issue = mock(Issue.class); + when(issue.getKey()).thenReturn("SLING-123"); + when(issue.getSummary()).thenReturn("Some bug"); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(null, successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + try (MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) { + userInput.when(() -> UserInput.yesNo(anyString(), any())).thenReturn(InputOption.YES); + Command command = createCommand(ExecutionMode.INTERACTIVE, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).create(anyString()); + verify(versionClient, times(1)).moveIssuesToNewVersion(any(), any(), any()); + } + } + + @Test + public void testInteractiveSkipsOnNo() throws Exception { + prepare(); + Version version = mock(Version.class); + when(version.getName()).thenReturn("1.0.0"); + Version successor = mock(Version.class); + when(successor.getName()).thenReturn("1.0.2"); + Issue issue = mock(Issue.class); + when(versionClient.find(any())).thenReturn(version); + when(versionClient.findSuccessorVersion(any())).thenReturn(successor); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue)); + + try (MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) { + userInput.when(() -> UserInput.yesNo(anyString(), any())).thenReturn(InputOption.NO); + Command command = createCommand(ExecutionMode.INTERACTIVE, VERSION_NAME); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, never()).moveIssuesToNewVersion(any(), any(), any()); + } + } + + @Test + public void testIOExceptionReturnsSoftware() throws Exception { + // releases() resolves from the staging repository; an IOException there surfaces as SOFTWARE + versionClient = mock(VersionClient.class); + osgiContext.registerService(VersionClient.class, versionClient); + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenThrow(new java.io.IOException("nexus down")); + osgiContext.registerService(RepositoryService.class, repositoryService); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed executing command")); + } + + private Command createCommandWithoutRepository(ExecutionMode executionMode) throws IllegalAccessException { + CreateJiraVersionCommand createJiraVersionCommand = spy(new CreateJiraVersionCommand()); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(createJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(createJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the CreateJiraVersionCommand from the mocked OSGi environment.", + result instanceof CreateJiraVersionCommand); + return result; + } + + private Command createCommand(ExecutionMode executionMode, String jiraVersionName) throws IllegalAccessException { + CreateJiraVersionCommand createJiraVersionCommand = spy(new CreateJiraVersionCommand()); + FieldUtils.writeField(createJiraVersionCommand, "repositoryId", 123, true); + FieldUtils.writeField(createJiraVersionCommand, "jiraVersionName", jiraVersionName, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(createJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(createJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the CreateJiraVersionCommand from the mocked OSGi environment.", + result instanceof CreateJiraVersionCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java new file mode 100644 index 0000000..d7715da --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java @@ -0,0 +1,152 @@ +/* + * 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.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.ExecutionMode; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; +import org.apache.sling.cli.impl.jira.Issue; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.junit.LogCapture; +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ReleaseJiraVersionCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(ReleaseJiraVersionCommand.class); + + private RepositoryService repositoryService; + private VersionClient versionClient; + + private void prepare() throws Exception { + StagingRepository stagingRepository = mock(StagingRepository.class); + repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + Set<Release> releases = + Set.of(Release.fromString("Apache Sling CLI Test 1.0.0").get(0)); + when(repositoryService.getReleases(stagingRepository)).thenReturn(releases); + + Issue issue = mock(Issue.class); + when(issue.getKey()).thenReturn("SLING-1"); + when(issue.getSummary()).thenReturn("A fixed issue"); + when(issue.getStatus()).thenReturn("Closed"); + when(issue.getResolution()).thenReturn("Fixed"); + + versionClient = mock(VersionClient.class); + when(versionClient.findFixedIssues(any())).thenReturn(List.of(issue)); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(VersionClient.class, versionClient); + } + + @Test + public void testDryRun() throws Exception { + prepare(); + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, never()).release(any()); + assertTrue(logCapture.containsMessage("The following Jira versions would be released:")); + } + + @Test + public void testInteractiveYes() throws Exception { + prepare(); + try (MockedStatic<UserInput> userInputMock = mockStatic(UserInput.class)) { + userInputMock + .when(() -> UserInput.yesNo(anyString(), any(InputOption.class))) + .thenReturn(InputOption.YES); + Command command = createCommand(123, ExecutionMode.INTERACTIVE); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).release(any()); + } + } + + @Test + public void testAuto() throws Exception { + prepare(); + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).release(any()); + } + + @Test + public void testReleaseByName() throws Exception { + // the release is resolved from --release, so the command works without the (now-gone) staging repo + prepare(); + Command command = createCommandByName("Apache Sling CLI Test 1.0.0", ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(versionClient, times(1)).release(any()); + verify(repositoryService, never()).find(anyInt()); + } + + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws Exception { + ReleaseJiraVersionCommand releaseJiraVersionCommand = spy(new ReleaseJiraVersionCommand()); + FieldUtils.writeField(releaseJiraVersionCommand, "repositoryId", repositoryId, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(releaseJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(releaseJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the ReleaseJiraVersionCommand from the mocked OSGi environment.", + result instanceof ReleaseJiraVersionCommand); + return result; + } + + private Command createCommandByName(String releaseName, ExecutionMode executionMode) throws Exception { + ReleaseJiraVersionCommand releaseJiraVersionCommand = spy(new ReleaseJiraVersionCommand()); + FieldUtils.writeField(releaseJiraVersionCommand, "releaseName", releaseName, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(releaseJiraVersionCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(releaseJiraVersionCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the ReleaseJiraVersionCommand from the mocked OSGi environment.", + result instanceof ReleaseJiraVersionCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java new file mode 100644 index 0000000..f034fb6 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java @@ -0,0 +1,154 @@ +/* + * 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.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; +import org.apache.sling.cli.impl.junit.LogCapture; +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.eclipse.jgit.api.CloneCommand; +import org.eclipse.jgit.api.DiffCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UpdateLocalSiteCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(UpdateLocalSiteCommand.class); + + /** + * Stubs out the JGit interactions so that no repository is cloned, opened or reset against the + * real filesystem or network. The {@code Git} instance returned by {@code Git.open(...)} is a + * mock whose {@code diff()} returns a mock {@link DiffCommand}. + */ + private MockedStatic<Git> stubGit() { + MockedStatic<Git> git = mockStatic(Git.class); + Git gitInstance = mock(Git.class); + // ensureRepo: the checkout already exists -> Git.open(...).reset()...call() + ResetCommand resetCommand = mock(ResetCommand.class); + when(resetCommand.setMode(any())).thenReturn(resetCommand); + when(gitInstance.reset()).thenReturn(resetCommand); + // call(): git.diff().setOutputStream(...).call() + DiffCommand diffCommand = mock(DiffCommand.class); + when(diffCommand.setOutputStream(any())).thenReturn(diffCommand); + when(gitInstance.diff()).thenReturn(diffCommand); + git.when(() -> Git.open(any())).thenReturn(gitInstance); + // ensureRepo: when the checkout does not yet exist, it is cloned instead + CloneCommand cloneCommand = mock(CloneCommand.class); + when(cloneCommand.setURI(any())).thenReturn(cloneCommand); + when(cloneCommand.setProgressMonitor(any())).thenReturn(cloneCommand); + when(cloneCommand.setDirectory(any())).thenReturn(cloneCommand); + git.when(Git::cloneRepository).thenReturn(cloneCommand); + return git; + } + + @Test + public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { + osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); + try (MockedStatic<Git> git = stubGit()) { + Command command = createCommand(null, null); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); + } + } + + @Test + public void testReleaseNameUpdatesContent() throws Exception { + osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); + try (MockedStatic<Git> git = stubGit(); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction(JBakeContentUpdater.class)) { + Command command = createCommand(null, "Apache Sling Foo 1.2.0"); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + JBakeContentUpdater instance = updater.constructed().get(0); + verify(instance).updateDownloads(any(), eq("Foo"), eq("1.2.0")); + verify(instance).updateReleases(any(), eq("Foo"), eq("1.2.0"), any()); + } + } + + @Test + public void testRepositoryResolvesReleasesFromService() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + StagingRepository repository = mock(StagingRepository.class); + when(repositoryService.find(123)).thenReturn(repository); + when(repositoryService.getReleases(repository)) + .thenReturn(Set.copyOf(Release.fromString("Apache Sling Bar 2.0.0"))); + osgiContext.registerService(RepositoryService.class, repositoryService); + + try (MockedStatic<Git> git = stubGit(); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction(JBakeContentUpdater.class)) { + Command command = createCommand(123, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + JBakeContentUpdater instance = updater.constructed().get(0); + verify(instance, atLeastOnce()).updateDownloads(any(), eq("Bar"), eq("2.0.0")); + } + } + + @Test + public void testIOExceptionReturnsSoftware() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenThrow(new IOException("nexus down")); + osgiContext.registerService(RepositoryService.class, repositoryService); + + try (MockedStatic<Git> git = stubGit()) { + Command command = createCommand(123, null); + assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed executing command")); + } + } + + private Command createCommand(Integer repositoryId, String releaseName) throws IllegalAccessException { + UpdateLocalSiteCommand updateLocalSiteCommand = spy(new UpdateLocalSiteCommand()); + FieldUtils.writeField(updateLocalSiteCommand, "repositoryId", repositoryId, true); + FieldUtils.writeField(updateLocalSiteCommand, "releaseName", releaseName, true); + osgiContext.registerInjectActivateService(updateLocalSiteCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the UpdateLocalSiteCommand from the mocked OSGi environment.", + result instanceof UpdateLocalSiteCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java index 10f53fa..fba8cc5 100644 --- a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java @@ -118,6 +118,43 @@ public class UpdateReporterCommandTest { verify(client, times(2)).execute(any()); } + @Test + public void testAutoByName() throws Exception { + // resolves the release from --release rather than a (possibly gone) staging repository + Command updateReporter = createCommandByName("Apache Sling CLI 1", ExecutionMode.AUTO); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + when(response.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(200); + when(client.execute(any())).thenReturn(response); + assertEquals(0, (int) updateReporter.call()); + verify(client, times(1)).execute(any()); + } + + @Test + public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { + UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand()); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", ExecutionMode.AUTO, true); + FieldUtils.writeField(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(updateReporterCommand); + Command command = osgiContext.getService(Command.class); + assertEquals(2, (int) command.call()); + assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); + verifyNoInteractions(client); + } + + @Test + public void testReporterFailureReturnsSoftware() throws Exception { + Command updateReporter = createCommand(42, ExecutionMode.AUTO); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + when(response.getStatusLine()).thenReturn(statusLine); + when(statusLine.getStatusCode()).thenReturn(500); + when(client.execute(any())).thenReturn(response); + assertEquals(1, (int) updateReporter.call()); + } + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws IllegalAccessException { UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand()); FieldUtils.writeField(updateReporterCommand, "repositoryId", repositoryId, true); @@ -131,4 +168,18 @@ public class UpdateReporterCommandTest { result instanceof UpdateReporterCommand); return result; } + + private Command createCommandByName(String releaseName, ExecutionMode executionMode) throws IllegalAccessException { + UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand()); + FieldUtils.writeField(updateReporterCommand, "releaseName", releaseName, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(updateReporterCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the UpdateReporterCommand from the mocked OSGi environment.", + result instanceof UpdateReporterCommand); + return result; + } } diff --git a/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java new file mode 100644 index 0000000..e7f606d --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java @@ -0,0 +1,127 @@ +/* + * 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.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.ci.CIStatusValidator; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.LocalRepository; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.pgp.HashValidator; +import org.apache.sling.cli.impl.pgp.PGPSignatureValidator; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class VerifyReleasesCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(VerifyReleasesCommand.class); + + @Test + public void testEmptyRepositoryIsValid() throws Exception { + // A downloaded repository with no artifacts runs no checks and is reported valid. + LocalRepository localRepository = mock(LocalRepository.class); + when(localRepository.getArtifacts()).thenReturn(Set.<Artifact>of()); + when(localRepository.getRootFolder()).thenReturn(Paths.get("/tmp")); + + RepositoryService repositoryService = mock(RepositoryService.class); + StagingRepository stagingRepository = mock(StagingRepository.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + when(repositoryService.download(stagingRepository)).thenReturn(localRepository); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(PGPSignatureValidator.class, mock(PGPSignatureValidator.class)); + osgiContext.registerService(CIStatusValidator.class, mock(CIStatusValidator.class)); + osgiContext.registerService(HashValidator.class, mock(HashValidator.class)); + + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("VALID (0 checks executed)")); + } + + @Test + public void testInvalidSignatureFailsVerification() throws Exception { + // One artifact whose signature, SHA-1 and MD5 all fail -> command reports INVALID. + StagingRepository stagingRepository = mock(StagingRepository.class); + Artifact jar = + new Artifact(stagingRepository, "org.apache.sling", "org.apache.sling.cli.test", "1.0.0", null, "jar"); + LocalRepository localRepository = mock(LocalRepository.class); + when(localRepository.getArtifacts()).thenReturn(Set.of(jar)); + when(localRepository.getRootFolder()).thenReturn(Paths.get("/tmp")); + + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + when(repositoryService.download(stagingRepository)).thenReturn(localRepository); + + PGPSignatureValidator pgp = mock(PGPSignatureValidator.class); + PGPSignatureValidator.ValidationResult pgpResult = mock(PGPSignatureValidator.ValidationResult.class); + when(pgpResult.isValid()).thenReturn(false); + when(pgp.verify(any(Path.class), any(Path.class))).thenReturn(pgpResult); + + HashValidator hashValidator = mock(HashValidator.class); + HashValidator.ValidationResult hashResult = mock(HashValidator.ValidationResult.class); + when(hashResult.isValid()).thenReturn(false); + when(hashResult.getExpectedHash()).thenReturn("expected"); + when(hashResult.getActualHash()).thenReturn("actual"); + when(hashValidator.validate(any(Path.class), any(Path.class), any())).thenReturn(hashResult); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(PGPSignatureValidator.class, pgp); + osgiContext.registerService(CIStatusValidator.class, mock(CIStatusValidator.class)); + osgiContext.registerService(HashValidator.class, hashValidator); + + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + assertTrue(logCapture.containsMessage("INVALID")); + } + + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws IllegalAccessException { + VerifyReleasesCommand verifyReleasesCommand = spy(new VerifyReleasesCommand()); + FieldUtils.writeField(verifyReleasesCommand, "repositoryId", repositoryId, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(verifyReleasesCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(verifyReleasesCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the VerifyReleasesCommand from the mocked OSGi environment.", + result instanceof VerifyReleasesCommand); + return result; + } +}
