diff --git a/src/main/java/org/openmbee/flexo/cli/FlexoCLI.java b/src/main/java/org/openmbee/flexo/cli/FlexoCLI.java index 9690ef2..a38dd27 100644 --- a/src/main/java/org/openmbee/flexo/cli/FlexoCLI.java +++ b/src/main/java/org/openmbee/flexo/cli/FlexoCLI.java @@ -1,6 +1,7 @@ package org.openmbee.flexo.cli; import org.openmbee.flexo.cli.commands.BranchCommand; +import org.openmbee.flexo.cli.commands.CollectionCommand; import org.openmbee.flexo.cli.commands.CommandExecutionException; import org.openmbee.flexo.cli.commands.InitCommand; import org.openmbee.flexo.cli.commands.MergeCommand; @@ -8,6 +9,7 @@ import org.openmbee.flexo.cli.commands.PushCommand; import org.openmbee.flexo.cli.commands.RemoteCommand; import org.openmbee.flexo.cli.commands.RmCommand; +import org.openmbee.flexo.cli.commands.SquashCommand; import org.openmbee.flexo.cli.commands.BaseCommand; import org.openmbee.flexo.cli.config.FlexoConfig; import org.openmbee.flexo.cli.plugin.FlexoPlugin; @@ -38,6 +40,8 @@ PushCommand.class, RmCommand.class, MergeCommand.class, + SquashCommand.class, + CollectionCommand.class, RemoteCommand.class, CommandLine.HelpCommand.class } @@ -119,6 +123,8 @@ public void run() { ConsoleUtil.info(" push - Commit model changes to a branch"); ConsoleUtil.info(" rm - Remove elements from the model"); ConsoleUtil.info(" merge - Merge changes between branches"); + ConsoleUtil.info(" squash - Squash commits between two locks"); + ConsoleUtil.info(" collection - Manage collections (groupings of refs)"); ConsoleUtil.info(""); ConsoleUtil.info("Use 'flexo --help' for more information about a command"); } diff --git a/src/main/java/org/openmbee/flexo/cli/client/FlexoMmsClient.java b/src/main/java/org/openmbee/flexo/cli/client/FlexoMmsClient.java index 775c80f..58ad792 100644 --- a/src/main/java/org/openmbee/flexo/cli/client/FlexoMmsClient.java +++ b/src/main/java/org/openmbee/flexo/cli/client/FlexoMmsClient.java @@ -11,6 +11,7 @@ import org.apache.jena.rdf.model.Model; import org.openmbee.flexo.cli.config.FlexoConfig; import org.openmbee.flexo.cli.model.Branch; +import org.openmbee.flexo.cli.model.Collection; import org.openmbee.flexo.cli.util.RdfParser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -242,6 +243,269 @@ public String createDiff(String orgId, String repoId, String sourceRef, String t } } + /** + * Squash the linear commit path between two locks into a single commit. + * + * Posts to the repo's /squash endpoint with a Turtle body referencing the + * source and destination lock IRIs via mms:srcRef and mms:dstRef. The + * service squashes the linear commit path between the two locks' commits + * into a single diff on the newer (destination) lock's commit. + * + * @param orgId Organization ID + * @param repoId Repository ID + * @param srcLock Source lock ID (or a full lock IRI) + * @param dstLock Destination lock ID (or a full lock IRI); must be the newer commit + * @return The commit ID of the resulting squashed commit + */ + public String squash(String orgId, String repoId, String srcLock, String dstLock) throws IOException { + String url = String.format("%s/orgs/%s/repos/%s/squash", baseUrl, orgId, repoId); + logger.debug("POST {}", url); + + String srcRef = resolveLockIri(orgId, repoId, srcLock); + String dstRef = resolveLockIri(orgId, repoId, dstLock); + + // Build RDF body referencing the two locks to squash between. + StringBuilder rdfBody = new StringBuilder(); + rdfBody.append("@prefix mms: .\n\n"); + rdfBody.append("<> mms:srcRef <").append(srcRef).append("> .\n"); + rdfBody.append("<> mms:dstRef <").append(dstRef).append("> .\n"); + + HttpPost request = new HttpPost(url); + addAuthHeader(request); + request.setHeader("Content-Type", "text/turtle"); + request.setEntity(new StringEntity(rdfBody.toString(), ContentType.parse("text/turtle"))); + + logger.debug("Squash RDF:\n{}", rdfBody.toString()); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getCode(); + String responseBody = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; + + if (statusCode >= 200 && statusCode < 300) { + logger.debug("Squash completed successfully"); + return extractCommitId(responseBody); + } else { + throw new IOException("Failed to squash commits: HTTP " + statusCode + " - " + responseBody); + } + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Failed to parse response", e); + } + } + + /** + * Resolve a lock reference to a full lock IRI. If the reference already + * looks like an absolute IRI it is returned unchanged; otherwise it is + * treated as a lock ID under the given repository and resolved against the + * server's root context (which may differ from the client base URL). + */ + private String resolveLockIri(String orgId, String repoId, String lockRef) throws IOException { + if (lockRef == null || lockRef.isEmpty()) { + return lockRef; + } + if (lockRef.startsWith("http://") || lockRef.startsWith("https://")) { + return lockRef; + } + return String.format("%s/orgs/%s/repos/%s/locks/%s", + getServerRootContext(orgId, repoId), orgId, repoId, lockRef); + } + + /** + * Resolve a ref (branch) name to a full branch IRI under the given repo, + * using the server's root context. Absolute IRIs are returned unchanged. + */ + public String resolveBranchIri(String orgId, String repoId, String refName) throws IOException { + if (refName == null || refName.isEmpty()) { + return refName; + } + if (refName.startsWith("http://") || refName.startsWith("https://")) { + return refName; + } + return String.format("%s/orgs/%s/repos/%s/branches/%s", + getServerRootContext(orgId, repoId), orgId, repoId, refName); + } + + /** + * Determine the server's root context (scheme + authority + any base path) + * under which refs are stored. The client base URL may differ from the + * server root context (e.g. a proxied or containerized MMS), and refs are + * validated against their stored IRIs, so we derive the root context from + * an existing branch's commit IRI. Falls back to the client base URL when + * no branch IRI is available. + */ + private String getServerRootContext(String orgId, String repoId) throws IOException { + try { + for (Branch branch : listBranches(orgId, repoId)) { + String commitId = branch.getCommitId(); + if (commitId != null) { + int idx = commitId.indexOf("/orgs/"); + if (idx > 0) { + return commitId.substring(0, idx); + } + } + } + } catch (IOException e) { + logger.debug("Could not resolve server root context, falling back to base URL: {}", + e.getMessage()); + } + return baseUrl; + } + + /** + * List all collections in an organization. + */ + public List listCollections(String orgId) throws IOException { + String url = String.format("%s/orgs/%s/collections", baseUrl, orgId); + logger.debug("GET {}", url); + + HttpGet request = new HttpGet(url); + addAuthHeader(request); + request.setHeader("Accept", "text/turtle"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getCode(); + String responseBody = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; + + if (statusCode >= 200 && statusCode < 300) { + return parseCollectionsFromRdf(responseBody); + } else { + throw new IOException("Failed to list collections: HTTP " + statusCode + " - " + responseBody); + } + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Failed to parse response", e); + } + } + + /** + * Get a specific collection. + */ + public Collection getCollection(String orgId, String collectionId) throws IOException { + String url = String.format("%s/orgs/%s/collections/%s", baseUrl, orgId, collectionId); + logger.debug("GET {}", url); + + HttpGet request = new HttpGet(url); + addAuthHeader(request); + request.setHeader("Accept", "text/turtle"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getCode(); + String responseBody = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; + + if (statusCode >= 200 && statusCode < 300) { + List collections = parseCollectionsFromRdf(responseBody); + return collections.isEmpty() ? null : collections.get(0); + } else { + throw new IOException("Failed to get collection: HTTP " + statusCode + " - " + responseBody); + } + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Failed to parse response", e); + } + } + + /** + * Create a new collection that groups the given refs. + * + * Puts to the collection resource with a Turtle body declaring one + * mms:collects statement per collected ref. The refs are branch, lock or + * scratch IRIs; relative IRIs are resolved against the collection resource. + * + * @param orgId Organization ID + * @param collectionId Collection ID (slug) + * @param refIris One or more ref IRIs to collect + * @return The created collection + */ + public Collection createCollection(String orgId, String collectionId, List refIris) throws IOException { + if (refIris == null || refIris.isEmpty()) { + throw new IOException("A collection requires at least one collected ref"); + } + + String url = String.format("%s/orgs/%s/collections/%s", baseUrl, orgId, collectionId); + logger.debug("PUT {}", url); + + // Build RDF body with an mms:collects statement per ref. + StringBuilder rdfBody = new StringBuilder(); + rdfBody.append("@prefix mms: .\n"); + rdfBody.append("@prefix dct: .\n\n"); + rdfBody.append("<> dct:title \"").append(collectionId).append("\"@en .\n"); + for (String refIri : refIris) { + rdfBody.append("<> mms:collects <").append(refIri).append("> .\n"); + } + + HttpPut request = new HttpPut(url); + addAuthHeader(request); + request.setHeader("Content-Type", "text/turtle"); + request.setEntity(new StringEntity(rdfBody.toString(), ContentType.parse("text/turtle"))); + + logger.debug("Collection creation RDF:\n{}", rdfBody.toString()); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getCode(); + String responseBody = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; + + if (statusCode >= 200 && statusCode < 300) { + logger.debug("Collection created successfully"); + return getCollection(orgId, collectionId); + } else { + throw new IOException("Failed to create collection: HTTP " + statusCode + " - " + responseBody); + } + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Failed to parse response", e); + } + } + + /** + * Get the union graph of all refs collected by a collection (pull operation). + */ + public Model getCollectionModel(String orgId, String collectionId, String format) throws IOException { + String url = String.format("%s/orgs/%s/collections/%s/graph", baseUrl, orgId, collectionId); + logger.debug("GET {}", url); + + HttpGet request = new HttpGet(url); + addAuthHeader(request); + request.setHeader("Accept", RdfParser.getContentType(format)); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getCode(); + String responseBody = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; + + if (statusCode >= 200 && statusCode < 300) { + return RdfParser.parseString(responseBody, format); + } else { + throw new IOException("Failed to get collection model: HTTP " + statusCode + " - " + responseBody); + } + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Failed to parse response", e); + } + } + + /** + * Run a SPARQL query across the union of all graphs collected by a collection. + * + * @return The raw query result body as returned by the service + */ + public String queryCollection(String orgId, String collectionId, String sparql) throws IOException { + String url = String.format("%s/orgs/%s/collections/%s/query", baseUrl, orgId, collectionId); + logger.debug("POST {}", url); + + HttpPost request = new HttpPost(url); + addAuthHeader(request); + request.setHeader("Content-Type", "application/sparql-query"); + request.setHeader("Accept", "application/sparql-results+json"); + request.setEntity(new StringEntity(sparql, ContentType.parse("application/sparql-query"))); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getCode(); + String responseBody = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : ""; + + if (statusCode >= 200 && statusCode < 300) { + return responseBody; + } else { + throw new IOException("Failed to query collection: HTTP " + statusCode + " - " + responseBody); + } + } catch (org.apache.hc.core5.http.ParseException e) { + throw new IOException("Failed to parse response", e); + } + } + /** * Execute arbitrary HTTP request with authentication */ @@ -344,6 +608,59 @@ private List parseBranchesFromRdf(String rdfContent) { return branches; } + private List parseCollectionsFromRdf(String rdfContent) { + List collections = new ArrayList<>(); + try { + Model model = RdfParser.parseString(rdfContent, "turtle"); + logger.debug("Parsed RDF model with {} statements", model.size()); + + String mmsNs = "https://mms.openmbee.org/rdf/ontology/"; + org.apache.jena.rdf.model.Property rdfType = model.getProperty("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"); + org.apache.jena.rdf.model.Resource collectionType = model.getResource(mmsNs + "Collection"); + org.apache.jena.rdf.model.Property mmsId = model.getProperty(mmsNs + "id"); + org.apache.jena.rdf.model.Property mmsEtag = model.getProperty(mmsNs + "etag"); + org.apache.jena.rdf.model.Property mmsCollects = model.getProperty(mmsNs + "collects"); + + org.apache.jena.rdf.model.ResIterator iter = model.listSubjectsWithProperty(rdfType, collectionType); + while (iter.hasNext()) { + org.apache.jena.rdf.model.Resource collectionRes = iter.nextResource(); + Collection collection = new Collection(); + + // Set ID from mms:id property or the URI's last path segment + if (collectionRes.hasProperty(mmsId)) { + collection.setId(collectionRes.getProperty(mmsId).getString()); + } else { + String uri = collectionRes.getURI(); + if (uri != null && uri.contains("/collections/")) { + collection.setId(uri.substring(uri.lastIndexOf("/") + 1)); + } + } + + // Set etag + if (collectionRes.hasProperty(mmsEtag)) { + collection.setEtag(collectionRes.getProperty(mmsEtag).getString()); + } + + // Collect all mms:collects ref IRIs + org.apache.jena.rdf.model.StmtIterator collectsIter = collectionRes.listProperties(mmsCollects); + while (collectsIter.hasNext()) { + org.apache.jena.rdf.model.Statement stmt = collectsIter.nextStatement(); + if (stmt.getObject().isResource()) { + collection.addCollectedRef(stmt.getObject().asResource().getURI()); + } + } + + collection.setName(collection.getId()); + collections.add(collection); + } + + logger.debug("Parsed {} collections from RDF", collections.size()); + } catch (Exception e) { + logger.error("Failed to parse collections from RDF: {}", e.getMessage(), e); + } + return collections; + } + /** * Matches a commit IRI of the form .../commits/<id> and captures the id. * The id segment stops at the next '/', '>' or whitespace. diff --git a/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java b/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java new file mode 100644 index 0000000..ee3642e --- /dev/null +++ b/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java @@ -0,0 +1,287 @@ +package org.openmbee.flexo.cli.commands; + +import org.openmbee.flexo.cli.FlexoCLI; +import org.openmbee.flexo.cli.client.FlexoMmsClient; +import org.openmbee.flexo.cli.config.FlexoConfig; +import org.openmbee.flexo.cli.model.Collection; +import org.openmbee.flexo.cli.util.ConsoleUtil; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +/** + * Collection command - Manage collections, lightweight groupings of refs + * (branches, locks or scratches) that can be queried as a single union graph. + * + * Collections are a Flexo MMS Layer 1 feature that extends the git-style + * interface with cross-ref querying. + */ +@Command( + name = "collection", + description = "Manage collections (groupings of refs queryable as a union)", + mixinStandardHelpOptions = true, + subcommands = { + CollectionCommand.ListCommand.class, + CollectionCommand.GetCommand.class, + CollectionCommand.CreateCommand.class, + CollectionCommand.QueryCommand.class + } +) +public class CollectionCommand extends BaseCommand { + + @ParentCommand + protected FlexoCLI parent; + + @Override + protected FlexoCLI getParentCli() { + return parent; + } + + @Override + protected void executeCommand() { + // Default behavior: list collections + ListCommand list = new ListCommand(); + list.parent = this; + list.run(); + } + + /** + * Base class for collection subcommands. Resolves the FlexoCLI root through + * the CollectionCommand parent so global options (--org, --remote) apply. + */ + abstract static class CollectionSubCommand extends BaseCommand { + + /** Subclasses expose their picocli-injected CollectionCommand parent. */ + protected abstract CollectionCommand getCollectionParent(); + + @Override + protected FlexoCLI getParentCli() { + CollectionCommand cc = getCollectionParent(); + return cc != null ? cc.parent : null; + } + + protected String requireOrg(FlexoConfig config) { + String orgId = getOrgId(config); + if (orgId == null || orgId.isEmpty()) { + throw new CommandExecutionException( + "Organization ID is required. Use --org or set default.org in config", 1); + } + return orgId; + } + } + + /** + * List all collections in the organization. + */ + @Command( + name = "list", + aliases = {"ls"}, + description = "List all collections in the organization", + mixinStandardHelpOptions = true + ) + static class ListCommand extends CollectionSubCommand { + @ParentCommand + protected CollectionCommand parent; + + @Override + protected CollectionCommand getCollectionParent() { + return parent; + } + + @Override + protected void executeCommand() throws Exception { + FlexoConfig config = getConfig(); + String orgId = requireOrg(config); + + try (FlexoMmsClient client = createClient(config, true)) { + ConsoleUtil.info("Listing collections in " + orgId + "..."); + + List collections = client.listCollections(orgId); + + if (collections.isEmpty()) { + ConsoleUtil.info("No collections found"); + return; + } + + String[] headers = {"Collection", "Collects", "ETag"}; + String[][] rows = new String[collections.size()][3]; + + for (int i = 0; i < collections.size(); i++) { + Collection collection = collections.get(i); + rows[i][0] = collection.getName() != null ? collection.getName() : collection.getId(); + rows[i][1] = String.valueOf(collection.getCollectedRefs().size()); + rows[i][2] = collection.getEtag() != null ? collection.getEtag() : "N/A"; + } + + ConsoleUtil.printTable(headers, rows); + } + } + } + + /** + * Get details of a single collection. + */ + @Command( + name = "get", + description = "Show details of a collection", + mixinStandardHelpOptions = true + ) + static class GetCommand extends CollectionSubCommand { + @ParentCommand + protected CollectionCommand parent; + + @Override + protected CollectionCommand getCollectionParent() { + return parent; + } + + @Parameters(index = "0", description = "Collection ID") + private String collectionId; + + @Override + protected void executeCommand() throws Exception { + FlexoConfig config = getConfig(); + String orgId = requireOrg(config); + + try (FlexoMmsClient client = createClient(config, true)) { + Collection collection = client.getCollection(orgId, collectionId); + + if (collection == null) { + throw new CommandExecutionException("Collection '" + collectionId + "' not found", 1); + } + + ConsoleUtil.info("Collection: " + collection.getId()); + if (collection.getEtag() != null) { + ConsoleUtil.info(" ETag: " + collection.getEtag()); + } + ConsoleUtil.info(" Collects (" + collection.getCollectedRefs().size() + "):"); + for (String ref : collection.getCollectedRefs()) { + ConsoleUtil.info(" " + ref); + } + } + } + } + + /** + * Create a new collection from one or more refs. + */ + @Command( + name = "create", + description = "Create a collection from one or more branches", + mixinStandardHelpOptions = true + ) + static class CreateCommand extends CollectionSubCommand { + @ParentCommand + protected CollectionCommand parent; + + @Override + protected CollectionCommand getCollectionParent() { + return parent; + } + + @Parameters(index = "0", description = "Collection ID (slug)") + private String collectionId; + + @Option(names = {"--ref"}, required = true, description = "Branch name to collect (repeatable)") + private List refs; + + @Override + protected void executeCommand() throws Exception { + FlexoConfig config = getConfig(); + String orgId = requireOrg(config); + String repoId = getRepoId(config); + validateOrgAndRepo(orgId, repoId); + + if (refs == null || refs.isEmpty()) { + throw new CommandExecutionException("At least one --ref is required", 1); + } + + try (FlexoMmsClient client = createClient(config, true)) { + // Resolve each ref name to a full branch IRI. The server stores + // refs under its own root context (e.g. http://layer1-service/...), + // which is not necessarily the client base URL, and validates each + // mms:collects target against the stored ref IRI. The client + // discovers the root context from existing branch IRIs. + java.util.List refIris = new java.util.ArrayList<>(); + for (String ref : refs) { + refIris.add(client.resolveBranchIri(orgId, repoId, ref)); + } + + ConsoleUtil.info("Creating collection '" + collectionId + "' in " + orgId + + " with " + refIris.size() + " ref(s)..."); + + Collection collection = client.createCollection(orgId, collectionId, refIris); + + if (collection != null) { + ConsoleUtil.success("Collection '" + collectionId + "' created successfully"); + ConsoleUtil.info("Collects " + collection.getCollectedRefs().size() + " ref(s)"); + } else { + throw new CommandExecutionException("Failed to create collection", 1); + } + } + } + } + + /** + * Run a SPARQL query across the union of a collection's collected graphs. + */ + @Command( + name = "query", + description = "Run a SPARQL query across a collection's union graph", + mixinStandardHelpOptions = true + ) + static class QueryCommand extends CollectionSubCommand { + @ParentCommand + protected CollectionCommand parent; + + @Override + protected CollectionCommand getCollectionParent() { + return parent; + } + + @Parameters(index = "0", description = "Collection ID") + private String collectionId; + + @Option(names = {"-q", "--query"}, description = "SPARQL query string") + private String query; + + @Option(names = {"-i", "--input"}, description = "Read SPARQL query from file") + private String inputFile; + + @Option(names = {"-o", "--output"}, description = "Output file (default: stdout)") + private String outputFile; + + @Override + protected void executeCommand() throws Exception { + FlexoConfig config = getConfig(); + String orgId = requireOrg(config); + + String sparql = query; + if ((sparql == null || sparql.isEmpty()) && inputFile != null && !inputFile.isEmpty()) { + sparql = new String(Files.readAllBytes(Paths.get(inputFile))); + } + if (sparql == null || sparql.isEmpty()) { + throw new CommandExecutionException( + "A SPARQL query is required. Use -q/--query or -i/--input", 1); + } + + try (FlexoMmsClient client = createClient(config, true)) { + ConsoleUtil.info("Querying collection '" + collectionId + "' in " + orgId + "..."); + + String result = client.queryCollection(orgId, collectionId, sparql); + + if (outputFile != null && !outputFile.isEmpty()) { + Files.write(Paths.get(outputFile), result.getBytes()); + ConsoleUtil.info("Saved to: " + outputFile); + } else { + System.out.println(result); + } + } + } + } +} diff --git a/src/main/java/org/openmbee/flexo/cli/commands/SquashCommand.java b/src/main/java/org/openmbee/flexo/cli/commands/SquashCommand.java new file mode 100644 index 0000000..2f57acf --- /dev/null +++ b/src/main/java/org/openmbee/flexo/cli/commands/SquashCommand.java @@ -0,0 +1,59 @@ +package org.openmbee.flexo.cli.commands; + +import org.openmbee.flexo.cli.FlexoCLI; +import org.openmbee.flexo.cli.client.FlexoMmsClient; +import org.openmbee.flexo.cli.config.FlexoConfig; +import org.openmbee.flexo.cli.util.ConsoleUtil; +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +/** + * Squash command - Collapse the linear commit path between two locks into a + * single commit. + * + * Both references are locks; the source lock marks the start of the range and + * the destination lock (which must point to the newer commit) receives the + * squashed commit. + */ +@Command( + name = "squash", + description = "Squash the linear commit path between two locks into a single commit", + mixinStandardHelpOptions = true +) +public class SquashCommand extends BaseCommand { + + @ParentCommand + protected FlexoCLI parent; + + @Override + protected FlexoCLI getParentCli() { + return parent; + } + + @Parameters(index = "0", description = "Source lock (start of the range)") + private String srcLock; + + @Parameters(index = "1", description = "Destination lock (newer commit, receives the squash)") + private String dstLock; + + @Override + protected void executeCommand() throws Exception { + FlexoConfig config = getConfig(); + + String orgId = getOrgId(config); + String repoId = getRepoId(config); + + validateOrgAndRepo(orgId, repoId); + + try (FlexoMmsClient client = createClient(config, true)) { + ConsoleUtil.info("Squashing commits in " + orgId + "/" + repoId + + " between locks '" + srcLock + "' and '" + dstLock + "'..."); + + String commitId = client.squash(orgId, repoId, srcLock, dstLock); + + ConsoleUtil.success("Squash completed successfully"); + ConsoleUtil.info("Commit: " + commitId); + } + } +} diff --git a/src/main/java/org/openmbee/flexo/cli/model/Collection.java b/src/main/java/org/openmbee/flexo/cli/model/Collection.java new file mode 100644 index 0000000..40a0f87 --- /dev/null +++ b/src/main/java/org/openmbee/flexo/cli/model/Collection.java @@ -0,0 +1,64 @@ +package org.openmbee.flexo.cli.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a collection in Flexo MMS. + * + * A collection is a lightweight grouping of refs (branches, locks or + * scratches) that can be queried as a single union graph. + */ +public class Collection { + private String id; + private String name; + private String etag; + private final List collectedRefs = new ArrayList<>(); + + public Collection() { + } + + public Collection(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEtag() { + return etag; + } + + public void setEtag(String etag) { + this.etag = etag; + } + + public List getCollectedRefs() { + return collectedRefs; + } + + public void addCollectedRef(String refIri) { + if (refIri != null) { + collectedRefs.add(refIri); + } + } + + @Override + public String toString() { + return "Collection{id='" + id + "', name='" + name + "', collects=" + collectedRefs.size() + "}"; + } +} diff --git a/src/test/java/org/openmbee/flexo/cli/client/FlexoMmsClientTest.java b/src/test/java/org/openmbee/flexo/cli/client/FlexoMmsClientTest.java index ed88f53..5046586 100644 --- a/src/test/java/org/openmbee/flexo/cli/client/FlexoMmsClientTest.java +++ b/src/test/java/org/openmbee/flexo/cli/client/FlexoMmsClientTest.java @@ -332,6 +332,166 @@ void testAuthenticationHeaderInjection() throws Exception { verify(mockAuthHandler).getAuthorizationHeader(); } + @Test + void testSquashSuccess() throws Exception { + String responseBody = "@prefix morc: ."; + + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream(responseBody.getBytes())); + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + String result = client.squash("org1", "repo1", + "http://example.com/orgs/org1/repos/repo1/locks/lock1", + "http://example.com/orgs/org1/repos/repo1/locks/lock2"); + + assertEquals("abc-123", result); + verify(mockHttpClient).execute(any(HttpPost.class)); + } + + @Test + void testSquashError() throws Exception { + when(mockResponse.getCode()).thenReturn(400); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream("Bad squash".getBytes())); + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + assertThrows(IOException.class, () -> client.squash("org1", "repo1", + "http://example.com/orgs/org1/repos/repo1/locks/lock1", + "http://example.com/orgs/org1/repos/repo1/locks/lock2")); + } + + @Test + void testListCollectionsSuccess() throws Exception { + String turtle = "@prefix mms: .\n" + + " a mms:Collection ;\n" + + " mms:id \"c1\" ;\n" + + " mms:collects ."; + + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream(turtle.getBytes())); + when(mockHttpClient.execute(any(HttpGet.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + List result = client.listCollections("org1"); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("c1", result.get(0).getId()); + assertTrue(result.get(0).getCollectedRefs().stream().anyMatch(r -> r.endsWith("/master"))); + } + + @Test + void testListCollectionsError() throws Exception { + when(mockResponse.getCode()).thenReturn(404); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream("Not found".getBytes())); + when(mockHttpClient.execute(any(HttpGet.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + assertThrows(IOException.class, () -> client.listCollections("org1")); + } + + @Test + void testGetCollectionSuccess() throws Exception { + String turtle = "@prefix mms: .\n" + + " a mms:Collection ;\n" + + " mms:id \"c1\" ;\n" + + " mms:etag \"etag-1\" ."; + + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream(turtle.getBytes())); + when(mockHttpClient.execute(any(HttpGet.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + org.openmbee.flexo.cli.model.Collection result = client.getCollection("org1", "c1"); + + assertNotNull(result); + assertEquals("c1", result.getId()); + assertEquals("etag-1", result.getEtag()); + } + + @Test + void testCreateCollectionEmptyRefsThrows() throws Exception { + FlexoMmsClient client = createClientWithMockedHttp(); + + assertThrows(IOException.class, + () -> client.createCollection("org1", "c1", java.util.Collections.emptyList())); + assertThrows(IOException.class, + () -> client.createCollection("org1", "c1", null)); + verify(mockHttpClient, never()).execute(any(HttpUriRequestBase.class)); + } + + @Test + void testQueryCollectionSuccess() throws Exception { + String sparqlResults = "{\"head\":{},\"results\":{\"bindings\":[]}}"; + + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream(sparqlResults.getBytes())); + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + String result = client.queryCollection("org1", "c1", "SELECT * WHERE { ?s ?p ?o }"); + + assertNotNull(result); + assertTrue(result.contains("bindings")); + verify(mockHttpClient).execute(any(HttpPost.class)); + } + + @Test + void testQueryCollectionError() throws Exception { + when(mockResponse.getCode()).thenReturn(400); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream("Bad query".getBytes())); + when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + assertThrows(IOException.class, + () -> client.queryCollection("org1", "c1", "INVALID")); + } + + @Test + void testResolveBranchIriUsesServerRootContext() throws Exception { + // Branch list carries the server root context (http://layer1-service), + // which differs from the client base URL (http://example.com). + String turtle = "@prefix mms: .\n" + + " a mms:Branch ;\n" + + " mms:id \"master\" ;\n" + + " mms:commit ."; + + when(mockResponse.getCode()).thenReturn(200); + when(mockResponse.getEntity()).thenReturn(mockEntity); + when(mockEntity.getContent()).thenReturn(new ByteArrayInputStream(turtle.getBytes())); + when(mockHttpClient.execute(any(HttpGet.class))).thenReturn(mockResponse); + + FlexoMmsClient client = createClientWithMockedHttp(); + + String iri = client.resolveBranchIri("org1", "repo1", "master"); + + assertEquals("http://layer1-service/orgs/org1/repos/repo1/branches/master", iri); + } + + @Test + void testResolveBranchIriPassesThroughAbsolute() throws Exception { + FlexoMmsClient client = createClientWithMockedHttp(); + String abs = "http://layer1-service/orgs/org1/repos/repo1/branches/feature"; + assertEquals(abs, client.resolveBranchIri("org1", "repo1", abs)); + // Absolute IRI must not trigger a branch-list lookup. + verify(mockHttpClient, never()).execute(any(HttpGet.class)); + } + private FlexoMmsClient createClientWithMockedHttp() throws Exception { FlexoMmsClient client = new FlexoMmsClient("http://example.com", null); injectMockHttpClient(client); diff --git a/src/test/java/org/openmbee/flexo/cli/commands/CollectionCommandTest.java b/src/test/java/org/openmbee/flexo/cli/commands/CollectionCommandTest.java new file mode 100644 index 0000000..fe4eb88 --- /dev/null +++ b/src/test/java/org/openmbee/flexo/cli/commands/CollectionCommandTest.java @@ -0,0 +1,18 @@ +package org.openmbee.flexo.cli.commands; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CollectionCommandTest { + + @Test + void testConstructor() { + CollectionCommand command = new CollectionCommand(); + assertNotNull(command); + } + + // Note: Full integration testing of commands requires static mocking of + // FlexoCLI.getConfig(), System.exit() handling, and HTTP client mocking. + // This test provides basic construction coverage. +} diff --git a/src/test/java/org/openmbee/flexo/cli/commands/SquashCommandTest.java b/src/test/java/org/openmbee/flexo/cli/commands/SquashCommandTest.java new file mode 100644 index 0000000..7390236 --- /dev/null +++ b/src/test/java/org/openmbee/flexo/cli/commands/SquashCommandTest.java @@ -0,0 +1,18 @@ +package org.openmbee.flexo.cli.commands; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SquashCommandTest { + + @Test + void testConstructor() { + SquashCommand command = new SquashCommand(); + assertNotNull(command); + } + + // Note: Full integration testing of commands requires static mocking of + // FlexoCLI.getConfig(), System.exit() handling, and HTTP client mocking. + // This test provides basic construction coverage. +}