From 7c374e4670186507f73d4e7fae0e7f9cce4b9270 Mon Sep 17 00:00:00 2001 From: Jason Han Date: Thu, 9 Jul 2026 21:53:53 -0700 Subject: [PATCH 1/2] Add squash and collection commands Add two new git-style/layer1 commands to reach closer feature parity with the flexo-mms-layer1-service recent endpoints: - squash : POST /orgs/{org}/repos/{repo}/squash, squashing the linear commit path between two locks into a single diff. - collection {list|get|create|query}: org-level collections (/orgs/{org}/collections), lightweight groupings of refs queryable as a union graph. FlexoMmsClient gains squash, listCollections, getCollection, createCollection, getCollectionModel and queryCollection methods plus a Collection model POJO and RDF parsing helper, all matching existing client/command conventions. Adds 8 client tests and 2 command smoke tests (308 tests total). --- .../java/org/openmbee/flexo/cli/FlexoCLI.java | 6 + .../flexo/cli/client/FlexoMmsClient.java | 274 +++++++++++++++++ .../flexo/cli/commands/CollectionCommand.java | 289 ++++++++++++++++++ .../flexo/cli/commands/SquashCommand.java | 59 ++++ .../openmbee/flexo/cli/model/Collection.java | 64 ++++ .../flexo/cli/client/FlexoMmsClientTest.java | 126 ++++++++ .../cli/commands/CollectionCommandTest.java | 18 ++ .../flexo/cli/commands/SquashCommandTest.java | 18 ++ 8 files changed, 854 insertions(+) create mode 100644 src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java create mode 100644 src/main/java/org/openmbee/flexo/cli/commands/SquashCommand.java create mode 100644 src/main/java/org/openmbee/flexo/cli/model/Collection.java create mode 100644 src/test/java/org/openmbee/flexo/cli/commands/CollectionCommandTest.java create mode 100644 src/test/java/org/openmbee/flexo/cli/commands/SquashCommandTest.java 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..02aceec 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,226 @@ 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. + */ + private String resolveLockIri(String orgId, String repoId, String lockRef) { + 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", baseUrl, orgId, repoId, lockRef); + } + + /** + * 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 +565,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..b43a1ff --- /dev/null +++ b/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java @@ -0,0 +1,289 @@ +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 under the current repo. + String baseUrl = client.getBaseUrl(); + java.util.List refIris = new java.util.ArrayList<>(); + for (String ref : refs) { + if (ref.startsWith("http://") || ref.startsWith("https://")) { + refIris.add(ref); + } else { + refIris.add(String.format("%s/orgs/%s/repos/%s/branches/%s", + baseUrl, 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..1b04201 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,132 @@ 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", "lock1", "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", "lock1", "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")); + } + 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. +} From 51662053570bd14379e8cf49897598c306a9c8d1 Mon Sep 17 00:00:00 2001 From: Jason Han Date: Thu, 9 Jul 2026 22:10:57 -0700 Subject: [PATCH 2/2] Resolve collection/lock refs against server root context Collection create (--ref names) and squash (lock names) resolved ref IRIs against the client base URL, but layer1-service stores and validates refs under its configured root context (e.g. http://layer1-service), causing HTTP 404 'Ref does not exist'. Add FlexoMmsClient.getServerRootContext() which derives the server root from an existing branch commit IRI, and route resolveBranchIri() and resolveLockIri() through it. Absolute http(s) IRIs pass through unchanged. Simplify CollectionCommand.CreateCommand to use resolveBranchIri(). Add tests. --- .../flexo/cli/client/FlexoMmsClient.java | 49 +++++++++++++++++-- .../flexo/cli/commands/CollectionCommand.java | 14 +++--- .../flexo/cli/client/FlexoMmsClientTest.java | 38 +++++++++++++- 3 files changed, 88 insertions(+), 13 deletions(-) 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 02aceec..58ad792 100644 --- a/src/main/java/org/openmbee/flexo/cli/client/FlexoMmsClient.java +++ b/src/main/java/org/openmbee/flexo/cli/client/FlexoMmsClient.java @@ -295,16 +295,59 @@ public String squash(String orgId, String repoId, String srcLock, String dstLock /** * 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. + * 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) { + 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", baseUrl, orgId, repoId, 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; } /** diff --git a/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java b/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java index b43a1ff..ee3642e 100644 --- a/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java +++ b/src/main/java/org/openmbee/flexo/cli/commands/CollectionCommand.java @@ -202,16 +202,14 @@ protected void executeCommand() throws Exception { } try (FlexoMmsClient client = createClient(config, true)) { - // Resolve each ref name to a full branch IRI under the current repo. - String baseUrl = client.getBaseUrl(); + // 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) { - if (ref.startsWith("http://") || ref.startsWith("https://")) { - refIris.add(ref); - } else { - refIris.add(String.format("%s/orgs/%s/repos/%s/branches/%s", - baseUrl, orgId, repoId, ref)); - } + refIris.add(client.resolveBranchIri(orgId, repoId, ref)); } ConsoleUtil.info("Creating collection '" + collectionId + "' in " + orgId 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 1b04201..5046586 100644 --- a/src/test/java/org/openmbee/flexo/cli/client/FlexoMmsClientTest.java +++ b/src/test/java/org/openmbee/flexo/cli/client/FlexoMmsClientTest.java @@ -343,7 +343,9 @@ void testSquashSuccess() throws Exception { FlexoMmsClient client = createClientWithMockedHttp(); - String result = client.squash("org1", "repo1", "lock1", "lock2"); + 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)); @@ -358,7 +360,9 @@ void testSquashError() throws Exception { FlexoMmsClient client = createClientWithMockedHttp(); - assertThrows(IOException.class, () -> client.squash("org1", "repo1", "lock1", "lock2")); + 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 @@ -458,6 +462,36 @@ void testQueryCollectionError() throws Exception { () -> 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);