This is an automated email from the ASF dual-hosted git repository.

royteeuwen pushed a commit to branch feature/sling-cli-release-automation
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git

commit 0e0a5d5f58da9b60979976bf883a7bb82a638fe3
Author: Roy Teeuwen <[email protected]>
AuthorDate: Sat May 30 08:09:57 2026 +0200

    release close-staging: command with reactor-aware description
    
    Add the `close-staging` command and the RepositoryService support it needs:
    staging bulk actions (close/promote/drop), findAny() (which also resolves 
open
    repositories), and content-based release derivation that works on an open 
repo
    not yet in the Lucene index.
    
    The staging description is derived from the staged POM(s) instead of Nexus'
    generic "Implicitly created (auto staging)" text. When the staged POMs form 
a
    single multi-module reactor (one staged pom-packaging aggregator that is the
    parent of the other staged modules and is itself the top of the staged
    hierarchy), the release is that aggregator alone; otherwise each staged 
module
    becomes its own release. Versions inherited from <parent> are resolved so
    reactor children are not skipped. getReleases() and getReleasesFromContent()
    share this reduction so the closed- and open-repo paths agree.
---
 .../sling/cli/impl/nexus/RepositoryService.java    | 252 +++++++++++++++++++--
 .../cli/impl/release/CloseStagingCommand.java      | 117 ++++++++++
 .../cli/impl/nexus/RepositoryServiceTest.java      |  41 ++++
 .../cli/impl/release/CloseStagingCommandTest.java  | 116 ++++++++++
 4 files changed, 504 insertions(+), 22 deletions(-)

diff --git 
a/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java 
b/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java
index 4625944..750c5ce 100644
--- a/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java
+++ b/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java
@@ -31,6 +31,8 @@ import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -50,6 +52,9 @@ import org.apache.commons.io.IOUtils;
 import org.apache.http.HttpHeaders;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.sling.cli.impl.ComponentContextHelper;
 import org.apache.sling.cli.impl.http.HttpClientFactory;
@@ -111,6 +116,65 @@ public class RepositoryService {
         });
     }
 
+    public StagingRepository findAny(int stagingRepositoryId) throws 
IOException {
+        return this.withStagingRepositories(reader -> {
+            Gson gson = new Gson();
+            return gson.fromJson(reader, 
StagingRepositories.class).getData().stream()
+                    .filter(r -> 
r.getRepositoryId().startsWith(REPOSITORY_PREFIX))
+                    .filter(r -> r.getRepositoryId().endsWith("-" + 
stagingRepositoryId))
+                    .findFirst()
+                    .orElseThrow(
+                            () -> new IllegalArgumentException("No repository 
found with id " + stagingRepositoryId));
+        });
+    }
+
+    public void close(StagingRepository repository) throws IOException {
+        executeBulkAction("close", repository.getRepositoryId(), 
Collections.emptyMap());
+    }
+
+    public void close(StagingRepository repository, String description) throws 
IOException {
+        executeBulkAction("close", repository.getRepositoryId(), 
Collections.singletonMap("description", description));
+    }
+
+    public void promote(StagingRepository repository) throws IOException {
+        // Nexus "Release": move the staged artifacts to the release 
repository (which syncs to Maven
+        // Central) and drop the staging repository afterwards. This matches 
the payload the Nexus UI
+        // sends. Note there is no targetRepositoryId — that field is for 
build-promotion profiles and
+        // is rejected with HTTP 400 by the bulk/promote endpoint.
+        executeBulkAction(
+                "promote", repository.getRepositoryId(), 
Collections.singletonMap("autoDropAfterRelease", true));
+    }
+
+    public void drop(StagingRepository repository) throws IOException {
+        executeBulkAction("delete", repository.getRepositoryId(), 
Collections.emptyMap());
+    }
+
+    private void executeBulkAction(String action, String repositoryId, 
Map<String, Object> extraData)
+            throws IOException {
+        try (CloseableHttpClient client = httpClientFactory.newClient()) {
+            HttpPost post = new HttpPost(nexusUrlPrefix + 
"/service/local/staging/bulk/" + action);
+            post.addHeader(HttpHeaders.ACCEPT, CONTENT_TYPE_JSON);
+
+            Map<String, Object> data = new HashMap<>();
+            data.put("stagedRepositoryIds", 
Collections.singletonList(repositoryId));
+            data.put("description", "");
+            data.putAll(extraData);
+
+            JsonObject body = new JsonObject();
+            body.add("data", new Gson().toJsonTree(data));
+
+            post.setEntity(new StringEntity(body.toString(), 
ContentType.APPLICATION_JSON));
+
+            try (CloseableHttpResponse response = client.execute(post)) {
+                int statusCode = response.getStatusLine().getStatusCode();
+                if (statusCode != 201) {
+                    throw new IOException(
+                            "Unexpected status " + statusCode + " for staging 
bulk/" + action + " on " + repositoryId);
+                }
+            }
+        }
+    }
+
     private <T> T withStagingRepositories(Function<InputStreamReader, T> 
function) throws IOException {
         try (CloseableHttpClient client = httpClientFactory.newClient()) {
             HttpGet get = 
newGet("/service/local/staging/profile_repositories");
@@ -231,42 +295,186 @@ public class RepositoryService {
     }
 
     public Set<Release> getReleases(StagingRepository stagingRepository) 
throws IOException {
-        Set<Release> releases = new HashSet<>();
+        List<PomCoordinates> poms = new ArrayList<>();
         getArtifacts(stagingRepository).stream()
                 .filter(artifact -> "pom".equals(artifact.getType()))
                 .forEach(pom -> {
                     try {
-                        XPath xPath = xPathFactory.newXPath();
                         processArtifactStream(pom, stream -> {
-                            try {
-                                DocumentBuilder builder = 
builderFactory.newDocumentBuilder();
-                                Document xmlDocument = builder.parse(stream);
-                                String name = (String) 
xPath.compile("/project/name/text()")
-                                        .evaluate(xmlDocument, 
XPathConstants.STRING);
-                                String version = (String) 
xPath.compile("/project/version/text()")
-                                        .evaluate(xmlDocument, 
XPathConstants.STRING);
-                                try {
-                                    releases.addAll(Release.fromString(name + 
" " + version));
-                                } catch (IllegalArgumentException e) {
-                                    LOGGER.error(
-                                            String.format(
-                                                    "Unable to determine a 
valid release from '%s %s'", name, version),
-                                            e);
-                                }
-                            } catch (ParserConfigurationException
-                                    | SAXException
-                                    | XPathExpressionException
-                                    | IOException e) {
-                                LOGGER.error(String.format("Unable to process 
artifact %s.", pom), e);
+                            PomCoordinates coordinates = parsePom(stream, 
pom.toString());
+                            if (coordinates != null) {
+                                poms.add(coordinates);
                             }
                         });
                     } catch (IOException e) {
                         LOGGER.error(String.format("Unable to process artifact 
%s.", pom), e);
                     }
                 });
+        return toReleases(poms);
+    }
+
+    /**
+     * Determines the releases contained in a staging repository by browsing 
its content directly,
+     * rather than relying on the Nexus Lucene search index. This works for 
<em>open</em> staging
+     * repositories too, whereas {@link #getReleases(StagingRepository)} only 
sees repositories that
+     * have already been closed and indexed.
+     */
+    public Set<Release> getReleasesFromContent(StagingRepository repository) 
throws IOException {
+        List<PomCoordinates> poms = new ArrayList<>();
+        try (CloseableHttpClient client = httpClientFactory.newClient()) {
+            List<String> pomPaths = new ArrayList<>();
+            collectPomPaths(client, repository.getRepositoryId(), 
"/org/apache/sling/", pomPaths);
+            for (String pomPath : pomPaths) {
+                HttpGet get =
+                        newGet("/service/local/repositories/" + 
repository.getRepositoryId() + "/content" + pomPath);
+                try (CloseableHttpResponse response = client.execute(get)) {
+                    if (response.getStatusLine().getStatusCode() != 200) {
+                        continue;
+                    }
+                    try (InputStream stream = 
response.getEntity().getContent()) {
+                        PomCoordinates coordinates = parsePom(stream, pomPath);
+                        if (coordinates != null) {
+                            poms.add(coordinates);
+                        }
+                    }
+                }
+            }
+        }
+        return toReleases(poms);
+    }
+
+    private void collectPomPaths(CloseableHttpClient client, String 
repositoryId, String path, List<String> pomPaths)
+            throws IOException {
+        HttpGet get = newGet("/service/local/repositories/" + repositoryId + 
"/content" + path);
+        try (CloseableHttpResponse response = client.execute(get)) {
+            if (response.getStatusLine().getStatusCode() != 200) {
+                return;
+            }
+            try (InputStream content = response.getEntity().getContent();
+                    InputStreamReader reader = new InputStreamReader(content)) 
{
+                JsonArray data = new JsonParser()
+                        .parse(reader)
+                        .getAsJsonObject()
+                        .get("data")
+                        .getAsJsonArray();
+                for (JsonElement element : data) {
+                    JsonObject entry = element.getAsJsonObject();
+                    String relativePath = 
entry.get("relativePath").getAsString();
+                    if (entry.get("leaf").getAsBoolean()) {
+                        if (entry.get("text").getAsString().endsWith(".pom")) {
+                            pomPaths.add(relativePath);
+                        }
+                    } else {
+                        collectPomPaths(client, repositoryId, relativePath, 
pomPaths);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * The Maven coordinates and name of a single staged POM, with versions 
resolved against the
+     * {@code <parent>} when a module inherits them.
+     */
+    record PomCoordinates(
+            String name, String groupId, String artifactId, String version, 
String packaging, String parentKey) {
+
+        /** {@code groupId:artifactId:version} identifying this artifact, or 
{@code null} if incomplete. */
+        String ownKey() {
+            return coordinateKey(groupId, artifactId, version);
+        }
+    }
+
+    private static String coordinateKey(String groupId, String artifactId, 
String version) {
+        if (groupId == null || groupId.isBlank() || artifactId == null || 
artifactId.isBlank()) {
+            return null;
+        }
+        return groupId + ":" + artifactId + ":" + version;
+    }
+
+    private PomCoordinates parsePom(InputStream stream, String pomLabel) {
+        try {
+            XPath xPath = xPathFactory.newXPath();
+            DocumentBuilder builder = builderFactory.newDocumentBuilder();
+            Document doc = builder.parse(stream);
+            String name = xpathString(xPath, doc, "/project/name/text()");
+            String artifactId = xpathString(xPath, doc, 
"/project/artifactId/text()");
+            String groupId = xpathString(xPath, doc, 
"/project/groupId/text()");
+            String version = xpathString(xPath, doc, 
"/project/version/text()");
+            String packaging = xpathString(xPath, doc, 
"/project/packaging/text()");
+            String parentGroupId = xpathString(xPath, doc, 
"/project/parent/groupId/text()");
+            String parentArtifactId = xpathString(xPath, doc, 
"/project/parent/artifactId/text()");
+            String parentVersion = xpathString(xPath, doc, 
"/project/parent/version/text()");
+            // In a multi-module reactor a child module's POM frequently omits 
<groupId>/<version> and
+            // inherits them from its <parent>; fall back so such modules are 
not skipped.
+            if (groupId == null || groupId.isBlank()) {
+                groupId = parentGroupId;
+            }
+            if (version == null || version.isBlank()) {
+                version = parentVersion;
+            }
+            if (packaging == null || packaging.isBlank()) {
+                packaging = "jar";
+            }
+            return new PomCoordinates(
+                    name,
+                    groupId,
+                    artifactId,
+                    version,
+                    packaging,
+                    coordinateKey(parentGroupId, parentArtifactId, 
parentVersion));
+        } catch (ParserConfigurationException | SAXException | 
XPathExpressionException | IOException e) {
+            LOGGER.error(String.format("Unable to process pom %s.", pomLabel), 
e);
+            return null;
+        }
+    }
+
+    private static String xpathString(XPath xPath, Document doc, String 
expression) throws XPathExpressionException {
+        return (String) xPath.compile(expression).evaluate(doc, 
XPathConstants.STRING);
+    }
+
+    /**
+     * Reduces the staged POMs to the set of releases they represent.
+     *
+     * <p>When the staged POMs form a single multi-module reactor — i.e. there 
is exactly one staged
+     * {@code pom}-packaging aggregator that is the {@code <parent>} of other 
staged modules and is
+     * itself the top of the staged hierarchy — the release is that aggregator 
alone (the reactor is
+     * one logical release, tracked by one JIRA version). Otherwise (a single 
module, or several
+     * independent modules staged and voted together) every staged module 
becomes its own release.
+     */
+    static Set<Release> toReleases(List<PomCoordinates> poms) {
+        Set<String> stagedKeys =
+                poms.stream().map(PomCoordinates::ownKey).filter(k -> k != 
null).collect(Collectors.toSet());
+        List<PomCoordinates> aggregators = poms.stream()
+                .filter(p -> "pom".equals(p.packaging()))
+                // is the parent of at least one other staged module
+                .filter(p -> p.ownKey() != null
+                        && poms.stream()
+                                .anyMatch(other -> other != p && 
p.ownKey().equals(other.parentKey())))
+                // and is the root of the staged hierarchy (its own parent is 
not itself staged, e.g. it
+                // is the shared org.apache.sling parent POM, which is not 
part of the release)
+                .filter(p -> p.parentKey() == null || 
!stagedKeys.contains(p.parentKey()))
+                .collect(Collectors.toList());
+        if (aggregators.size() == 1) {
+            return buildReleases(aggregators.get(0));
+        }
+        Set<Release> releases = new HashSet<>();
+        for (PomCoordinates pom : poms) {
+            releases.addAll(buildReleases(pom));
+        }
         return Set.copyOf(releases);
     }
 
+    private static Set<Release> buildReleases(PomCoordinates pom) {
+        try {
+            return new HashSet<>(Release.fromString(pom.name() + " " + 
pom.version()));
+        } catch (IllegalArgumentException e) {
+            LOGGER.error(
+                    String.format("Unable to determine a valid release from 
'%s %s'", pom.name(), pom.version()), e);
+            return Set.of();
+        }
+    }
+
     private void downloadFileFromRepository(
             @NotNull StagingRepository repository,
             @NotNull CloseableHttpClient client,
diff --git 
a/src/main/java/org/apache/sling/cli/impl/release/CloseStagingCommand.java 
b/src/main/java/org/apache/sling/cli/impl/release/CloseStagingCommand.java
new file mode 100644
index 0000000..5bd38f1
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/CloseStagingCommand.java
@@ -0,0 +1,117 @@
+/*
+ * 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.stream.Collectors;
+
+import org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.InputOption;
+import org.apache.sling.cli.impl.UserInput;
+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 picocli.CommandLine;
+
+@Component(
+        service = Command.class,
+        property = {
+            Command.PROPERTY_NAME_COMMAND_GROUP + "=" + 
CloseStagingCommand.GROUP,
+            Command.PROPERTY_NAME_COMMAND_NAME + "=" + CloseStagingCommand.NAME
+        })
[email protected](
+        name = CloseStagingCommand.NAME,
+        description =
+                "Closes an open Nexus staging repository after 'mvn 
release:perform', making it ready for verification and voting",
+        subcommands = CommandLine.HelpCommand.class)
+public class CloseStagingCommand implements Command {
+
+    static final String GROUP = "release";
+    static final String NAME = "close-staging";
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(CloseStagingCommand.class);
+
+    @CommandLine.Option(
+            names = {"-r", "--repository"},
+            description = "Nexus staging repository id (numeric part, e.g. 
1087)",
+            required = true)
+    private Integer repositoryId;
+
+    @CommandLine.Mixin
+    private ReusableCLIOptions reusableCLIOptions;
+
+    @Reference
+    private RepositoryService repositoryService;
+
+    @Override
+    public Integer call() {
+        try {
+            StagingRepository repository = 
repositoryService.findAny(repositoryId);
+            // Derive the description from the staged artifacts' POM (name + 
version), since
+            // releases staged via the plain maven-deploy-plugin only get the 
generic Nexus
+            // "Implicitly created (auto staging)" description. The repository 
is still open at
+            // this point, so it is not in the Lucene index yet; browse its 
content directly.
+            String description = 
repositoryService.getReleasesFromContent(repository).stream()
+                    .map(Release::getFullName)
+                    .collect(Collectors.joining(", "));
+            if (description.isEmpty()) {
+                description = repository.getDescription();
+            }
+            switch (reusableCLIOptions.executionMode) {
+                case DRY_RUN:
+                    LOGGER.info(
+                            "Would close staging repository {} with 
description \"{}\".",
+                            repository.getRepositoryId(),
+                            description);
+                    break;
+                case INTERACTIVE:
+                    InputOption answer = UserInput.yesNo(
+                            String.format(
+                                    "Close staging repository %s with 
description \"%s\"?",
+                                    repository.getRepositoryId(), description),
+                            InputOption.YES);
+                    if (InputOption.YES.equals(answer)) {
+                        doClose(repository, description);
+                    } else {
+                        LOGGER.info("Aborted.");
+                    }
+                    break;
+                case AUTO:
+                    doClose(repository, description);
+                    break;
+            }
+        } catch (IOException e) {
+            LOGGER.warn("Failed executing command", e);
+            return CommandLine.ExitCode.SOFTWARE;
+        }
+        return CommandLine.ExitCode.OK;
+    }
+
+    private void doClose(StagingRepository repository, String description) 
throws IOException {
+        LOGGER.info(
+                "Closing staging repository {} with description \"{}\"...", 
repository.getRepositoryId(), description);
+        repositoryService.close(repository, description);
+        LOGGER.info(
+                "Done. Repository {} is now closed and ready for verification 
and voting.",
+                repository.getRepositoryId());
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java 
b/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java
index 58f1732..62bdb9b 100644
--- a/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java
+++ b/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java
@@ -170,6 +170,47 @@ public class RepositoryServiceTest {
         assertEquals("Sling Adapter Annotations 1.0.0", release.getFullName());
     }
 
+    @Test
+    public void testToReleasesSingleModule() {
+        // a POM's <name> is the bare component name; the version comes from 
<version>
+        Set<Release> releases = RepositoryService.toReleases(List.of(
+                pom("Apache Sling Foo", "org.apache.sling", 
"org.apache.sling.foo", "1.2.0", "jar", PARENT_POM)));
+        assertEquals(Set.of("Apache Sling Foo 1.2.0"), fullNames(releases));
+    }
+
+    @Test
+    public void testToReleasesIndependentModulesAreAllReturned() {
+        // several unrelated modules staged + voted together: each keeps its 
own release/JIRA version
+        Set<Release> releases = RepositoryService.toReleases(List.of(
+                pom("Apache Sling Foo", "org.apache.sling", 
"org.apache.sling.foo", "1.2.0", "jar", PARENT_POM),
+                pom("Apache Sling Bar", "org.apache.sling", 
"org.apache.sling.bar", "2.0.0", "jar", PARENT_POM)));
+        assertEquals(Set.of("Apache Sling Foo 1.2.0", "Apache Sling Bar 
2.0.0"), fullNames(releases));
+    }
+
+    @Test
+    public void testToReleasesReactorCollapsesToAggregator() {
+        // a real reactor: a pom-packaging aggregator that is the parent of 
the staged child modules.
+        // The whole reactor is one logical release -> only the aggregator's 
name is returned.
+        String reactorKey = "org.apache.sling:org.apache.sling.reactor:1.0.0";
+        Set<Release> releases = RepositoryService.toReleases(List.of(
+                pom("Apache Sling Reactor", "org.apache.sling", 
"org.apache.sling.reactor", "1.0.0", "pom", PARENT_POM),
+                // children inherit groupId/version from the reactor parent
+                pom("Apache Sling Reactor Core", "org.apache.sling", "core", 
"1.0.0", "jar", reactorKey),
+                pom("Apache Sling Reactor API", "org.apache.sling", "api", 
"1.0.0", "jar", reactorKey)));
+        assertEquals(Set.of("Apache Sling Reactor 1.0.0"), 
fullNames(releases));
+    }
+
+    private static final String PARENT_POM = "org.apache.sling:sling:66";
+
+    private static RepositoryService.PomCoordinates pom(
+            String name, String groupId, String artifactId, String version, 
String packaging, String parentKey) {
+        return new RepositoryService.PomCoordinates(name, groupId, artifactId, 
version, packaging, parentKey);
+    }
+
+    private static Set<String> fullNames(Set<Release> releases) {
+        return 
releases.stream().map(Release::getFullName).collect(java.util.stream.Collectors.toSet());
+    }
+
     private StagingRepository getStagingRepository() {
         StagingRepository stagingRepository = new StagingRepository();
         stagingRepository.setRepositoryId("orgapachesling-0");
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/CloseStagingCommandTest.java 
b/src/test/java/org/apache/sling/cli/impl/release/CloseStagingCommandTest.java
new file mode 100644
index 0000000..638a4e2
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/cli/impl/release/CloseStagingCommandTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.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.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.Before;
+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.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 CloseStagingCommandTest {
+
+    @Rule
+    public final OsgiContext osgiContext = new OsgiContext();
+
+    @Rule
+    public final LogCapture logCapture = new 
LogCapture(CloseStagingCommand.class);
+
+    private RepositoryService repositoryService;
+    private StagingRepository stagingRepository;
+
+    @Before
+    public void before() throws Exception {
+        stagingRepository = mock(StagingRepository.class);
+        
when(stagingRepository.getRepositoryId()).thenReturn("orgapachesling-123");
+        when(stagingRepository.getDescription()).thenReturn("Apache Sling CLI 
Test 1.0.0");
+
+        repositoryService = mock(RepositoryService.class);
+        when(repositoryService.findAny(123)).thenReturn(stagingRepository);
+        Set<Release> releases =
+                Set.of(Release.fromString("Apache Sling CLI Test 
1.0.0").get(0));
+        
when(repositoryService.getReleasesFromContent(stagingRepository)).thenReturn(releases);
+
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+    }
+
+    @Test
+    public void testDryRun() throws Exception {
+        Command command = createCommand(123, ExecutionMode.DRY_RUN);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        assertTrue(logCapture.containsMessage(
+                "Would close staging repository orgapachesling-123 with 
description \"Apache Sling CLI Test 1.0.0\"."));
+        verify(repositoryService, never()).close(any(), any());
+    }
+
+    @Test
+    public void testInteractiveYes() throws Exception {
+        try (MockedStatic<UserInput> userInputMock = 
mockStatic(UserInput.class)) {
+            String question =
+                    "Close staging repository orgapachesling-123 with 
description \"Apache Sling CLI Test 1.0.0\"?";
+            userInputMock.when(() -> UserInput.yesNo(question, 
InputOption.YES)).thenReturn(InputOption.YES);
+            Command command = createCommand(123, ExecutionMode.INTERACTIVE);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+            verify(repositoryService).close(stagingRepository, "Apache Sling 
CLI Test 1.0.0");
+        }
+    }
+
+    @Test
+    public void testAuto() throws Exception {
+        Command command = createCommand(123, ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(repositoryService, times(1)).close(stagingRepository, "Apache 
Sling CLI Test 1.0.0");
+    }
+
+    private Command createCommand(int repositoryId, ExecutionMode 
executionMode) throws IllegalAccessException {
+        CloseStagingCommand closeStagingCommand = spy(new 
CloseStagingCommand());
+        FieldUtils.writeField(closeStagingCommand, "repositoryId", 
repositoryId, true);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(closeStagingCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(closeStagingCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the CloseStagingCommand from the mocked 
OSGi environment.",
+                result instanceof CloseStagingCommand);
+        return result;
+    }
+}

Reply via email to