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

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

commit b18e14b36ce68a7e48b96b10ae7589682a3efd38
Author: Roy Teeuwen <[email protected]>
AuthorDate: Sat Jul 4 15:02:19 2026 +0200

    SLING-13253 - release list: include open Nexus staging repositories
    
    Extends 'release list' to also show open Nexus staging repositories and adds
    the RepositoryService/StagingRepository support the post-vote release 
commands
    build on.
    
    Part of splitting PR #28 into per-command changes.
---
 .../sling/cli/impl/nexus/RepositoryService.java    | 253 +++++++++++++++++++--
 .../sling/cli/impl/nexus/StagingRepository.java    |  14 ++
 .../apache/sling/cli/impl/release/ListCommand.java |   7 +-
 .../org/apache/sling/cli/impl/nexus/MockNexus.java |  10 +
 .../nexus/RepositoryContentListingHandler.java     |  58 +++++
 .../cli/impl/nexus/RepositoryServiceTest.java      | 138 ++++++++++-
 .../cli/impl/nexus/StagingRepositoryTest.java      |  43 ++++
 .../sling/cli/impl/release/ListCommandTest.java    |  99 ++++++++
 .../1.0.0/adapter-annotations-1.0.0.pom            |  40 ++++
 .../sling/adapter-annotations/1.0.0/listing.json   |  14 ++
 .../apache/sling/adapter-annotations/listing.json  |   9 +
 .../orgapachesling-3/org/apache/sling/listing.json |   9 +
 src/test/resources/nexus/staging-repositories.json |  24 ++
 13 files changed, 692 insertions(+), 26 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 31946aa..7f7d55a 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;
@@ -93,7 +98,6 @@ public class RepositoryService {
         return this.withStagingRepositories(reader -> {
             Gson gson = new Gson();
             return gson.fromJson(reader, 
StagingRepositories.class).getData().stream()
-                    .filter(r -> r.getType() == Status.closed)
                     .filter(r -> 
r.getRepositoryId().startsWith(REPOSITORY_PREFIX))
                     .collect(Collectors.toList());
         });
@@ -112,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");
@@ -232,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()))
+                .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/nexus/StagingRepository.java 
b/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepository.java
index af7d0c0..efcfbb1 100644
--- a/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepository.java
+++ b/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepository.java
@@ -33,6 +33,7 @@ public class StagingRepository {
     protected String repositoryId;
     protected String repositoryURI;
     protected Status type;
+    protected String userId;
 
     public String getDescription() {
         return description;
@@ -42,6 +43,19 @@ public class StagingRepository {
         this.description = description;
     }
 
+    /**
+     * Returns the id of the committer who staged this repository, as reported 
by Nexus.
+     *
+     * @return the staging user id
+     */
+    public String getUserId() {
+        return userId;
+    }
+
+    public void setUserId(String userId) {
+        this.userId = userId;
+    }
+
     public String getRepositoryId() {
         return repositoryId;
     }
diff --git a/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java 
b/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java
index 43cc36a..c75ac0d 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java
@@ -57,7 +57,12 @@ public class ListCommand implements Command {
         try {
             repositoryService
                     .list()
-                    .forEach(r -> logger.info("{}\t{}", r.getRepositoryId(), 
cleanupNewlines(r.getDescription())));
+                    .forEach(r -> logger.info(
+                            "{}\t[{}]\t{}\t{}",
+                            r.getRepositoryId(),
+                            r.getType(),
+                            r.getUserId(),
+                            cleanupNewlines(r.getDescription())));
             return CommandLine.ExitCode.OK;
         } catch (IOException e) {
             logger.warn("Failed executing command", e);
diff --git a/src/test/java/org/apache/sling/cli/impl/nexus/MockNexus.java 
b/src/test/java/org/apache/sling/cli/impl/nexus/MockNexus.java
index c4e76b5..8e84352 100644
--- a/src/test/java/org/apache/sling/cli/impl/nexus/MockNexus.java
+++ b/src/test/java/org/apache/sling/cli/impl/nexus/MockNexus.java
@@ -65,7 +65,17 @@ public class MockNexus extends ExternalResource {
         List<HttpExchangeHandler> handlers = new ArrayList<>();
         handlers.add(new QueryLuceneIndexHandler());
         handlers.add(new StagingRepositoriesHandler());
+        handlers.add(new RepositoryContentListingHandler());
         handlers.add(new RepositoryContentHandler());
+        // staging bulk actions (close/promote/delete) are POSTs answered with 
HTTP 201
+        handlers.add(ex -> {
+            if ("POST".equals(ex.getRequestMethod())
+                    && 
ex.getRequestURI().getPath().startsWith("/service/local/staging/bulk/")) {
+                ex.sendResponseHeaders(201, -1);
+                return true;
+            }
+            return false;
+        });
         handlers.add(ex -> {
             ex.sendResponseHeaders(400, -1);
             return true;
diff --git 
a/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryContentListingHandler.java
 
b/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryContentListingHandler.java
new file mode 100644
index 0000000..4401d81
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryContentListingHandler.java
@@ -0,0 +1,58 @@
+/*
+ * 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.nexus;
+
+import java.io.IOException;
+
+import com.sun.net.httpserver.HttpExchange;
+import org.apache.sling.cli.impl.http.HttpExchangeHandler;
+
+/**
+ * Serves the Nexus repository content-browsing API used by
+ * {@code RepositoryService#getReleasesFromContent}. Requests to
+ * {@code /service/local/repositories/<id>/content/<path>} are mapped to 
classpath resources under
+ * {@code /nexus-content/<id>/<path>}; directory paths (ending in {@code /}) 
are served from a
+ * sibling {@code listing.json} fixture, raw files are served as-is.
+ */
+public class RepositoryContentListingHandler implements HttpExchangeHandler {
+
+    private static final String PREFIX = "/service/local/repositories/";
+
+    @Override
+    public boolean tryHandle(HttpExchange ex) throws IOException {
+        String path = ex.getRequestURI().getPath();
+        if (!"GET".equals(ex.getRequestMethod()) || !path.startsWith(PREFIX)) {
+            return false;
+        }
+        int contentIdx = path.indexOf("/content");
+        if (contentIdx < 0) {
+            return false;
+        }
+        String repoAndAfter = path.substring(PREFIX.length(), contentIdx); // 
e.g. orgapachesling-3
+        String relative = path.substring(contentIdx + "/content".length()); // 
e.g. /org/apache/sling/
+        String resource;
+        if (relative.endsWith("/")) {
+            resource = "/nexus-content/" + repoAndAfter + relative + 
"listing.json";
+        } else {
+            resource = "/nexus-content/" + repoAndAfter + relative;
+        }
+        serveFileFromClasspath(ex, resource);
+        return true;
+    }
+}
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 55e387b..97f4ce1 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
@@ -96,8 +96,10 @@ public class RepositoryServiceTest {
     @Test
     public void testRepositoryList() throws IOException {
         List<StagingRepository> stagingRepositories = repositoryService.list();
-        assertEquals(2, stagingRepositories.size());
-        Set<String> repositoriesIds = new HashSet<>(Set.of("orgapachesling-0", 
"orgapachesling-1"));
+        // Includes both closed repositories and the open (not yet closed) 
one, so that newly
+        // staged repositories show up before they have been closed for voting.
+        assertEquals(3, stagingRepositories.size());
+        Set<String> repositoriesIds = new HashSet<>(Set.of("orgapachesling-0", 
"orgapachesling-1", "orgapachesling-2"));
         for (StagingRepository repository : stagingRepositories) {
             assertEquals(
                     "http://localhost:"; + nexus.getBoundPort() + 
"/content/repositories/"
@@ -168,6 +170,138 @@ public class RepositoryServiceTest {
         assertEquals("Sling Adapter Annotations 1.0.0", release.getFullName());
     }
 
+    @Test
+    public void testGetReleasesFromContent() throws IOException {
+        // browses the repository content tree directly (no Lucene index), 
recursing into directories
+        // and parsing every .pom leaf it finds
+        StagingRepository repository = new StagingRepository();
+        repository.setRepositoryId("orgapachesling-3");
+        Set<Release> releases = 
repositoryService.getReleasesFromContent(repository);
+        assertEquals(1, releases.size());
+        assertEquals(
+                "Sling Adapter Annotations 1.0.0", 
releases.iterator().next().getFullName());
+    }
+
+    @Test
+    public void testFindAnyReturnsOpenRepository() throws IOException {
+        // findAny does not require the repository to be closed, so the open 
orgapachesling-2 resolves
+        StagingRepository repository = repositoryService.findAny(2);
+        assertNotNull(repository);
+        assertEquals("orgapachesling-2", repository.getRepositoryId());
+    }
+
+    @Test
+    public void testFindAnyUnknownThrows() {
+        try {
+            repositoryService.findAny(999);
+            fail("Expected an IllegalArgumentException for an unknown 
repository id.");
+        } catch (IllegalArgumentException e) {
+            assertTrue(e.getMessage().contains("999"));
+        } catch (IOException e) {
+            fail("Unexpected IOException.");
+        }
+    }
+
+    @Test
+    public void testPromote() throws IOException {
+        // exercises the bulk-action POST path; MockNexus answers staging bulk 
actions with HTTP 201
+        repositoryService.promote(getStagingRepository());
+    }
+
+    @Test
+    public void testCloseWithDescription() throws IOException {
+        repositoryService.close(getStagingRepository(), "voting");
+    }
+
+    @Test
+    public void testDrop() throws IOException {
+        repositoryService.drop(getStagingRepository());
+    }
+
+    @Test
+    public void testParsePomInheritsGroupIdAndVersionFromParent() throws 
Exception {
+        // a child module POM omitting <groupId>/<version>/<packaging> 
inherits them from <parent>
+        String pomXml = "<project>"
+                + "<name>Apache Sling Child</name>"
+                + "<artifactId>child</artifactId>"
+                + "<parent>"
+                + "<groupId>org.apache.sling</groupId>"
+                + "<artifactId>parent</artifactId>"
+                + "<version>3.4.5</version>"
+                + "</parent>"
+                + "</project>";
+        RepositoryService.PomCoordinates coordinates = invokeParsePom(pomXml);
+        assertNotNull(coordinates);
+        assertEquals("org.apache.sling", coordinates.groupId());
+        assertEquals("3.4.5", coordinates.version());
+        assertEquals("jar", coordinates.packaging());
+    }
+
+    @Test
+    public void testParsePomInvalidXmlReturnsNull() throws Exception {
+        // malformed XML triggers the error branch which logs and returns null
+        assertEquals(null, invokeParsePom("this is not xml"));
+    }
+
+    @Test
+    public void testToReleasesSkipsInvalidReleaseNames() {
+        // a POM whose name/version cannot be parsed into a Release yields an 
empty result rather than
+        // throwing, exercising the buildReleases error branch
+        Set<Release> releases = RepositoryService.toReleases(
+                List.of(pom("", "org.apache.sling", "org.apache.sling.broken", 
"", "jar", PARENT_POM)));
+        assertTrue(releases.isEmpty());
+    }
+
+    private RepositoryService.PomCoordinates invokeParsePom(String pomXml) 
throws Exception {
+        java.lang.reflect.Method method =
+                RepositoryService.class.getDeclaredMethod("parsePom", 
InputStream.class, String.class);
+        method.setAccessible(true);
+        try (InputStream stream = new 
java.io.ByteArrayInputStream(pomXml.getBytes(StandardCharsets.UTF_8))) {
+            return (RepositoryService.PomCoordinates) 
method.invoke(repositoryService, stream, "test.pom");
+        }
+    }
+
+    @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/nexus/StagingRepositoryTest.java 
b/src/test/java/org/apache/sling/cli/impl/nexus/StagingRepositoryTest.java
new file mode 100644
index 0000000..5b46ab2
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/nexus/StagingRepositoryTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.nexus;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class StagingRepositoryTest {
+
+    @Test
+    public void testAccessors() {
+        StagingRepository repository = new StagingRepository();
+        repository.setDescription("Apache Sling Foo 1.0.0");
+        repository.setRepositoryId("orgapachesling-7");
+        
repository.setRepositoryURI("https://repository.apache.org/content/repositories/orgapachesling-7";);
+        repository.setUserId("johndoe");
+        repository.setType(StagingRepository.Status.closed);
+
+        assertEquals("Apache Sling Foo 1.0.0", repository.getDescription());
+        assertEquals("orgapachesling-7", repository.getRepositoryId());
+        assertEquals(
+                
"https://repository.apache.org/content/repositories/orgapachesling-7";, 
repository.getRepositoryURI());
+        assertEquals("johndoe", repository.getUserId());
+        assertEquals(StagingRepository.Status.closed, repository.getType());
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/ListCommandTest.java 
b/src/test/java/org/apache/sling/cli/impl/release/ListCommandTest.java
new file mode 100644
index 0000000..24551d0
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/release/ListCommandTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.Arrays;
+import java.util.Collections;
+
+import org.apache.sling.cli.impl.Command;
+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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+public class ListCommandTest {
+
+    @Rule
+    public OsgiContext osgiContext = new OsgiContext();
+
+    @Rule
+    public final LogCapture logCapture = new LogCapture(ListCommand.class);
+
+    @Test
+    public void testList() throws Exception {
+        StagingRepository repo1 = mock(StagingRepository.class);
+        when(repo1.getRepositoryId()).thenReturn("orgapachesling-1");
+        when(repo1.getUserId()).thenReturn("jagger");
+        when(repo1.getDescription()).thenReturn("Apache Sling CLI Test 1.0.0");
+
+        StagingRepository repo2 = mock(StagingRepository.class);
+        when(repo2.getRepositoryId()).thenReturn("orgapachesling-2");
+        when(repo2.getUserId()).thenReturn("richards");
+        when(repo2.getDescription()).thenReturn("Apache Sling CLI Test 2.0.0");
+
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        when(repositoryService.list()).thenReturn(Arrays.asList(repo1, repo2));
+        osgiContext.registerService(repositoryService);
+
+        Command list = createCommand();
+        assertEquals(0, (int) list.call());
+
+        assertTrue(logCapture.containsMessage("orgapachesling-1"));
+        assertTrue(logCapture.containsMessage("orgapachesling-2"));
+        // the staging user is shown alongside the id/state/description
+        assertTrue(logCapture.containsMessage("jagger"));
+        assertTrue(logCapture.containsMessage("richards"));
+        assertTrue(logCapture.containsMessage("Apache Sling CLI Test 1.0.0"));
+        assertTrue(logCapture.containsMessage("Apache Sling CLI Test 2.0.0"));
+    }
+
+    @Test
+    public void testListCollapsesNewlines() throws Exception {
+        StagingRepository repo = mock(StagingRepository.class);
+        when(repo.getRepositoryId()).thenReturn("orgapachesling-1");
+        when(repo.getDescription()).thenReturn("line1\nline2");
+
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        
when(repositoryService.list()).thenReturn(Collections.singletonList(repo));
+        osgiContext.registerService(repositoryService);
+
+        Command list = createCommand();
+        assertEquals(0, (int) list.call());
+
+        assertTrue(logCapture.containsMessage("line1 line2"));
+    }
+
+    private Command createCommand() {
+        ListCommand listCommand = spy(new ListCommand());
+        osgiContext.registerInjectActivateService(listCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the ListCommand from the mocked OSGi 
environment.",
+                result instanceof ListCommand);
+        return result;
+    }
+}
diff --git 
a/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/1.0.0/adapter-annotations-1.0.0.pom
 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/1.0.0/adapter-annotations-1.0.0.pom
new file mode 100644
index 0000000..b9a3013
--- /dev/null
+++ 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/1.0.0/adapter-annotations-1.0.0.pom
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <artifactId>sling</artifactId>
+        <groupId>org.apache.sling</groupId>
+        <version>12</version>
+        <relativePath>../../parent/pom.xml</relativePath>
+    </parent>
+    <groupId>org.apache.sling</groupId>
+    <artifactId>adapter-annotations</artifactId>
+    <version>1.0.0</version>
+    <name>Sling Adapter Annotations</name>
+    <description>Annotations used to generate Sling Adapter 
metadata</description>
+
+    <scm>
+        
<connection>scm:svn:http://svn.apache.org/repos/asf/sling/tags/adapter-annotations-1.0.0</connection>
+        
<developerConnection>scm:svn:https://svn.apache.org/repos/asf/sling/tags/adapter-annotations-1.0.0</developerConnection>
+        
<url>http://svn.apache.org/viewvc/sling/tags/adapter-annotations-1.0.0</url>
+    </scm>
+</project>
\ No newline at end of file
diff --git 
a/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/1.0.0/listing.json
 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/1.0.0/listing.json
new file mode 100644
index 0000000..4a26249
--- /dev/null
+++ 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/1.0.0/listing.json
@@ -0,0 +1,14 @@
+{
+  "data": [
+    {
+      "relativePath": 
"/org/apache/sling/adapter-annotations/1.0.0/adapter-annotations-1.0.0.pom",
+      "text": "adapter-annotations-1.0.0.pom",
+      "leaf": true
+    },
+    {
+      "relativePath": 
"/org/apache/sling/adapter-annotations/1.0.0/adapter-annotations-1.0.0.jar",
+      "text": "adapter-annotations-1.0.0.jar",
+      "leaf": true
+    }
+  ]
+}
diff --git 
a/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/listing.json
 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/listing.json
new file mode 100644
index 0000000..f925f34
--- /dev/null
+++ 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/adapter-annotations/listing.json
@@ -0,0 +1,9 @@
+{
+  "data": [
+    {
+      "relativePath": "/org/apache/sling/adapter-annotations/1.0.0/",
+      "text": "1.0.0",
+      "leaf": false
+    }
+  ]
+}
diff --git 
a/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/listing.json
 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/listing.json
new file mode 100644
index 0000000..71a203e
--- /dev/null
+++ 
b/src/test/resources/nexus-content/orgapachesling-3/org/apache/sling/listing.json
@@ -0,0 +1,9 @@
+{
+  "data": [
+    {
+      "relativePath": "/org/apache/sling/adapter-annotations/",
+      "text": "adapter-annotations",
+      "leaf": false
+    }
+  ]
+}
diff --git a/src/test/resources/nexus/staging-repositories.json 
b/src/test/resources/nexus/staging-repositories.json
index 1d418f7..3b644c0 100644
--- a/src/test/resources/nexus/staging-repositories.json
+++ b/src/test/resources/nexus/staging-repositories.json
@@ -47,6 +47,30 @@
             "releaseRepositoryName": "Releases",
             "notifications"        : 0,
             "transitioning"        : false
+        },
+        {
+            "profileId"            : "6a443ac86e2212",
+            "profileName"          : "org.apache.sling",
+            "profileType"          : "repository",
+            "repositoryId"         : "orgapachesling-2",
+            "type"                 : "open",
+            "policy"               : "release",
+            "userId"               : "radu",
+            "userAgent"            : "Apache-Maven/3.6.1 (Java 1.8.0_222; Mac 
OS X 10.14.6)",
+            "ipAddress"            : "127.0.0.1",
+            "repositoryURI"        : 
"{nexusHost}/content/repositories/orgapachesling-2",
+            "created"              : "2019-09-13T00:00:00.000Z",
+            "createdDate"          : "Fri Sep 13 00:00:00 UTC 2019",
+            "createdTimestamp"     : 1568332800000,
+            "updated"              : "2019-09-13T00:00:00.000Z",
+            "updatedDate"          : "Fri Sep 13 00:00:00 UTC 2019",
+            "updatedTimestamp"     : 1568332800000,
+            "description"          : "Implicitly created (auto staging).",
+            "provider"             : "maven2",
+            "releaseRepositoryId"  : "releases",
+            "releaseRepositoryName": "Releases",
+            "notifications"        : 0,
+            "transitioning"        : false
         }
     ]
 }


Reply via email to