diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 72c36309..f787b4bb 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1469,12 +1469,6 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, -{ - "name":"io.seqera.tower.cli.commands.pipelines.LabelsCmd", - "allDeclaredFields":true, - "queryAllDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] -}, { "name":"io.seqera.tower.cli.commands.pipelines.LaunchOptions", "allDeclaredFields":true, @@ -1511,6 +1505,48 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.labels.LabelsCmd", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.ListCmd", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.UpdateCmd", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.UpdateCmd$UpdateOptions", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions$VersionRef", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.VersionsCmd", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.pipelineschemas.AbstractPipelineSchemasCmd", "allDeclaredFields":true, @@ -2187,12 +2223,30 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.PipelinesUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelines.PipelinesView", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.versions.ListPipelineVersionsCmdResponse", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.pipelines.versions.ManagePipelineVersionCmdResponse", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.pipelineschemas.PipelineSchemasAdded", "allDeclaredFields":true, @@ -4027,6 +4081,7 @@ }, { "name":"io.seqera.tower.model.ListPipelineVersionsResponse", + "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"addVersionsItem","parameterTypes":["io.seqera.tower.model.PipelineDbDto"] }, {"name":"equals","parameterTypes":["java.lang.Object"] }, {"name":"getTotalSize","parameterTypes":[] }, {"name":"getVersions","parameterTypes":[] }, {"name":"hashCode","parameterTypes":[] }, {"name":"setTotalSize","parameterTypes":["java.lang.Long"] }, {"name":"setVersions","parameterTypes":["java.util.List"] }, {"name":"toIndentedString","parameterTypes":["java.lang.Object"] }, {"name":"toString","parameterTypes":[] }, {"name":"totalSize","parameterTypes":["java.lang.Long"] }, {"name":"versions","parameterTypes":["java.util.List"] }] @@ -4352,6 +4407,7 @@ }, { "name":"io.seqera.tower.model.PipelineVersionManageRequest", + "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"equals","parameterTypes":["java.lang.Object"] }, {"name":"getIsDefault","parameterTypes":[] }, {"name":"getName","parameterTypes":[] }, {"name":"hashCode","parameterTypes":[] }, {"name":"isDefault","parameterTypes":["java.lang.Boolean"] }, {"name":"name","parameterTypes":["java.lang.String"] }, {"name":"setIsDefault","parameterTypes":["java.lang.Boolean"] }, {"name":"setName","parameterTypes":["java.lang.String"] }, {"name":"toIndentedString","parameterTypes":["java.lang.Object"] }, {"name":"toString","parameterTypes":[] }] diff --git a/conf/resource-config.json b/conf/resource-config.json index 49fe282f..ff9d95a9 100644 --- a/conf/resource-config.json +++ b/conf/resource-config.json @@ -82,10 +82,10 @@ "locales":["und"] }, { "name":"org.glassfish.jersey.client.internal.localization", - "locales":["und"] + "locales":["", "und"] }, { "name":"org.glassfish.jersey.internal.localization", - "locales":["und"] + "locales":["", "und"] }, { "name":"org.glassfish.jersey.media.multipart.internal.localization" }] diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 93204e1d..20d15b2d 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -30,6 +30,7 @@ import io.seqera.tower.api.OrgsApi; import io.seqera.tower.api.PipelineSchemasApi; import io.seqera.tower.api.PipelineSecretsApi; +import io.seqera.tower.api.PipelineVersionsApi; import io.seqera.tower.api.PipelinesApi; import io.seqera.tower.api.PlatformsApi; import io.seqera.tower.api.ServiceInfoApi; @@ -43,6 +44,7 @@ import io.seqera.tower.cli.Tower; import io.seqera.tower.cli.commands.labels.Label; import io.seqera.tower.cli.commands.labels.LabelsFinder; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.exceptions.ComputeEnvNotFoundException; import io.seqera.tower.cli.exceptions.InvalidWorkspaceParameterException; import io.seqera.tower.cli.exceptions.MissingTowerAccessTokenException; @@ -59,10 +61,12 @@ import io.seqera.tower.model.Credentials; import io.seqera.tower.model.DataStudioQueryAttribute; import io.seqera.tower.model.ListComputeEnvsResponseEntry; +import io.seqera.tower.model.ListPipelineVersionsResponse; import io.seqera.tower.model.ListWorkspacesAndOrgResponse; import io.seqera.tower.model.OrgAndWorkspaceDto; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineQueryAttribute; +import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.UserResponseDto; import io.seqera.tower.model.WorkflowQueryAttribute; import org.glassfish.jersey.CommonProperties; @@ -82,6 +86,7 @@ import java.util.Map; import java.util.Objects; import java.util.Properties; +import java.util.function.Predicate; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -115,6 +120,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private PipelinesApi pipelinesApi; private PipelineSchemasApi pipelineSchemasApi; private PipelineSecretsApi pipelineSecretsApi; + private PipelineVersionsApi pipelineVersionsApi; private PlatformsApi platformsApi; private ServiceInfoApi serviceInfoApi; private StudiosApi studiosApi; @@ -234,6 +240,10 @@ protected PipelinesApi pipelinesApi() throws ApiException { return pipelinesApi == null ? new PipelinesApi(apiClient()) : pipelinesApi; } + protected PipelineVersionsApi pipelineVersionsApi() throws ApiException { + return pipelineVersionsApi == null ? new PipelineVersionsApi(apiClient()) : pipelineVersionsApi; + } + protected PlatformsApi platformsApi() throws ApiException { return platformsApi == null ? new PlatformsApi(apiClient()) : platformsApi; } @@ -579,6 +589,39 @@ protected String workspaceRef(Long workspaceId) throws ApiException { return buildWorkspaceRef(orgName(workspaceId), workspaceName(workspaceId)); } + protected PipelineVersionFullInfoDto findPipelineVersionByRef(Long pipelineId, Long wspId, VersionRefOptions.VersionRef ref) throws ApiException { + String search = ref.versionName; + Boolean isPublished = ref.versionName != null ? true : null; + Predicate matcher = ref.versionId != null + ? v -> ref.versionId.equals(v.getId()) + : v -> ref.versionName.equals(v.getName()); + + ListPipelineVersionsResponse response = pipelineVersionsApi() + .listPipelineVersions(pipelineId, wspId, null, null, search, isPublished); + + if (response.getVersions() == null) { + throw new TowerException("No versions available for the pipeline, check if Pipeline versioning feature is enabled for the workspace"); + } + + return response.getVersions().stream() + .map(PipelineDbDto::getVersion) + .filter(Objects::nonNull) + .filter(matcher) + .findFirst() + .orElse(null); + } + + protected String resolvePipelineVersionId(Long pipelineId, Long wspId, VersionRefOptions.VersionRef versionRef) throws ApiException { + if (versionRef == null) return null; + if (versionRef.versionId != null) return versionRef.versionId; + + PipelineVersionFullInfoDto version = findPipelineVersionByRef(pipelineId, wspId, versionRef); + if (version == null) { + throw new TowerException(String.format("Pipeline version '%s' not found", versionRef.versionName)); + } + return version.getId(); + } + protected Long sourceWorkspaceId(Long currentWorkspace, PipelineDbDto pipeline) { if (pipeline == null) return null; diff --git a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java index 8f8ffe06..a975b115 100644 --- a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java @@ -20,6 +20,7 @@ import io.seqera.tower.cli.commands.enums.OutputType; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; import io.seqera.tower.cli.commands.labels.Label; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.exceptions.InvalidResponseException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.runs.RunSubmited; @@ -82,6 +83,10 @@ public class LaunchCmd extends AbstractRootCmd { @Option(names = {"--commit-id"}, description = "Specific Git commit hash to pin the pipeline execution to.") String commitId; + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. + @ArgGroup(multiplicity = "0..1") + VersionRefOptions.VersionRef versionRef; + @Option(names = {"--wait"}, description = "Wait until workflow reaches specified status: ${COMPLETION-CANDIDATES}") public WorkflowStatus wait; @@ -177,8 +182,9 @@ protected Response runTowerPipeline(Long wspId) throws ApiException, IOException } Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipe); + String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); - LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipe.getPipelineId(), wspId, sourceWorkspaceId, null).getLaunch(); + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipe.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); WorkflowLaunchRequest launchRequest = createLaunchRequest(launch); if (computeEnv != null) { diff --git a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java index 7fd2fcd5..f4c50ce5 100644 --- a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java @@ -20,10 +20,11 @@ import io.seqera.tower.cli.commands.pipelines.DeleteCmd; import io.seqera.tower.cli.commands.pipelines.ExportCmd; import io.seqera.tower.cli.commands.pipelines.ImportCmd; -import io.seqera.tower.cli.commands.pipelines.LabelsCmd; +import io.seqera.tower.cli.commands.pipelines.labels.LabelsCmd; import io.seqera.tower.cli.commands.pipelines.ListCmd; import io.seqera.tower.cli.commands.pipelines.UpdateCmd; import io.seqera.tower.cli.commands.pipelines.ViewCmd; +import io.seqera.tower.cli.commands.pipelines.versions.VersionsCmd; import picocli.CommandLine.Command; @@ -39,6 +40,7 @@ ExportCmd.class, ImportCmd.class, LabelsCmd.class, + VersionsCmd.class } ) public class PipelinesCmd extends AbstractRootCmd { diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java index b6412770..b5a2913e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/AddCmd.java @@ -19,12 +19,16 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; import io.seqera.tower.cli.commands.labels.LabelsOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.labels.PipelinesLabelsManager; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesAdded; import io.seqera.tower.cli.utils.FilesHelper; +import io.seqera.tower.cli.utils.ResponseHelper; import io.seqera.tower.model.ComputeEnvResponseDto; import io.seqera.tower.model.CreatePipelineRequest; import io.seqera.tower.model.CreatePipelineResponse; +import io.seqera.tower.model.CreatePipelineVersionRequest; import io.seqera.tower.model.Visibility; import io.seqera.tower.model.WorkflowLaunchRequest; import picocli.CommandLine; @@ -55,6 +59,9 @@ public class AddCmd extends AbstractPipelinesCmd { @Parameters(index = "0", paramLabel = "PIPELINE_URL", description = "Nextflow pipeline URL", arity = "1") public String pipeline; + @Option(names = {"--version-name"}, description = "Initial pipeline version name.") + public String versionName; + @Mixin public LabelsOptionalOptions labels; @@ -77,7 +84,7 @@ protected Response exec() throws ApiException, IOException { // Retrieve the provided computeEnv or use the primary if not provided ComputeEnvResponseDto ce = opts.computeEnv != null ? computeEnvByRef(wspId, opts.computeEnv) : null; - // By default use primary compute environment at private workspaces + // By default, use primary compute environment at private workspaces if (ce == null && visibility == Visibility.PRIVATE) { ce = primaryComputeEnv(wspId); if (ce == null) { @@ -90,36 +97,46 @@ protected Response exec() throws ApiException, IOException { String preRunScriptValue = opts.preRunScript == null && ce != null ? ce.getConfig().getPreRunScript() : FilesHelper.readString(opts.preRunScript); String postRunScriptValue = opts.postRunScript == null && ce != null ? ce.getConfig().getPostRunScript() : FilesHelper.readString(opts.postRunScript); - CreatePipelineResponse response = pipelinesApi().createPipeline( - new CreatePipelineRequest() - .name(name) - .description(description) - .launch(new WorkflowLaunchRequest() - .computeEnvId(ce != null ? ce.getId() : null) - .pipeline(pipeline) - .revision(opts.revision) - .commitId(opts.commitId) - .workDir(workDirValue) - .configProfiles(opts.profile) - .paramsText(FilesHelper.readString(opts.paramsFile)) - - // Advanced options - .configText(FilesHelper.readString(opts.config)) - .preRunScript(preRunScriptValue) - .postRunScript(postRunScriptValue) - .pullLatest(opts.pullLatest) - .stubRun(opts.stubRun) - .mainScript(opts.mainScript) - .entryName(opts.entryName) - .schemaName(opts.schemaName) - .pipelineSchemaId(pipelineSchemaId) - .userSecrets(removeEmptyValues(opts.userSecrets)) - .workspaceSecrets(removeEmptyValues(opts.workspaceSecrets)) - ) - , wspId - ); - - attachLabels(wspId,response.getPipeline().getPipelineId()); + CreatePipelineResponse response; + try { + response = pipelinesApi().createPipeline( + new CreatePipelineRequest() + .name(name) + .description(description) + .version(versionName != null ? new CreatePipelineVersionRequest().name(versionName) : null) + .launch(new WorkflowLaunchRequest() + .computeEnvId(ce != null ? ce.getId() : null) + .pipeline(pipeline) + .revision(opts.revision) + .commitId(opts.commitId) + .workDir(workDirValue) + .configProfiles(opts.profile) + .paramsText(FilesHelper.readString(opts.paramsFile)) + + // Advanced options + .configText(FilesHelper.readString(opts.config)) + .preRunScript(preRunScriptValue) + .postRunScript(postRunScriptValue) + .pullLatest(opts.pullLatest) + .stubRun(opts.stubRun) + .mainScript(opts.mainScript) + .entryName(opts.entryName) + .schemaName(opts.schemaName) + .pipelineSchemaId(pipelineSchemaId) + .userSecrets(removeEmptyValues(opts.userSecrets)) + .workspaceSecrets(removeEmptyValues(opts.workspaceSecrets)) + ) + , wspId + ); + } catch (ApiException e) { + throw new TowerException(String.format("Unable to add pipeline '%s': %s", name, ResponseHelper.decodeMessage(e))); + } + + try { + attachLabels(wspId, response.getPipeline().getPipelineId()); + } catch (ApiException e) { + throw new TowerException(String.format("Pipeline '%s' was created but failed to add labels: %s", name, ResponseHelper.decodeMessage(e))); + } return new PipelinesAdded(workspaceRef(wspId), response.getPipeline().getName()); } diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/ExportCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/ExportCmd.java index 51a42661..301befe2 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/ExportCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/ExportCmd.java @@ -20,13 +20,17 @@ import io.seqera.tower.ApiException; import io.seqera.tower.JSON; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesExport; import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.cli.utils.ModelHelper; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.model.CreatePipelineRequest; +import io.seqera.tower.model.CreatePipelineVersionRequest; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.WorkflowLaunchRequest; import picocli.CommandLine; @@ -42,6 +46,10 @@ public class ExportCmd extends AbstractPipelinesCmd { @CommandLine.Mixin public WorkspaceOptionalOptions workspace; + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. + @CommandLine.ArgGroup(multiplicity = "0..1") + public VersionRefOptions.VersionRef versionRef; + @CommandLine.Parameters(index = "0", paramLabel = "FILENAME", description = "File name to export", arity = "0..1") String fileName = null; @@ -50,7 +58,21 @@ protected Response exec() throws ApiException { Long wspId = workspaceId(workspace.workspace); PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipeline); - LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, null).getLaunch(); + + PipelineVersionFullInfoDto version = null; + String versionId = null; + if (versionRef != null) { + version = findPipelineVersionByRef(pipeline.getPipelineId(), wspId, versionRef); + if (version != null) { + versionId = version.getId(); + } else if (versionRef.versionId != null) { + versionId = versionRef.versionId; + } else { + throw new TowerException(String.format("Pipeline version '%s' not found", versionRef.versionName)); + } + } + + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); WorkflowLaunchRequest workflowLaunchRequest = ModelHelper.createLaunchRequest(launch); @@ -58,6 +80,9 @@ protected Response exec() throws ApiException { createPipelineRequest.setDescription(pipeline.getDescription()); createPipelineRequest.setIcon(pipeline.getIcon()); createPipelineRequest.setLaunch(workflowLaunchRequest); + if (version != null && version.getName() != null) { + createPipelineRequest.setVersion(new CreatePipelineVersionRequest().name(version.getName())); + } String configOutput = ""; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java index 26612a0c..c68cee63 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java @@ -18,13 +18,18 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.exceptions.InvalidResponseException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesUpdated; import io.seqera.tower.cli.utils.FilesHelper; +import io.seqera.tower.cli.utils.VersionNameHelper; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionFullInfoDto; +import io.seqera.tower.model.PipelineVersionManageRequest; import io.seqera.tower.model.UpdatePipelineRequest; +import io.seqera.tower.model.UpdatePipelineResponse; import io.seqera.tower.model.WorkflowLaunchRequest; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -32,7 +37,6 @@ import picocli.CommandLine.Option; import java.io.IOException; -import java.util.Collections; import static io.seqera.tower.cli.utils.ModelHelper.coalesce; import static io.seqera.tower.cli.utils.ModelHelper.removeEmptyValues; @@ -64,44 +68,138 @@ public class UpdateCmd extends AbstractPipelinesCmd { @Option(names = {"--pipeline"}, description = "Nextflow pipeline URL") public String pipeline; + @Option(names = {"--allow-draft"}, description = "If versionable fields change, keep the new version as an unnamed draft instead of auto-naming and promoting it to default.") + public boolean allowDraft; + + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. + @CommandLine.ArgGroup(multiplicity = "0..1") + public VersionRefOptions.VersionRef versionRef; + @Override protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); + + // Fetch uses describePipeline, which returns the default version on pipe.getVersion() + PipelineDbDto pipe = fetchPipeline(pipelineRefOptions, wspId); + + // If the user wants to rename the pipeline (--new-name), validate early before making any + // changes. The server checks uniqueness within the workspace/org scope. + validateNewName(newName, wspId); + + // Determine which version to update. If the user passed --version-id or --version-name, we + // target that specific version; otherwise we target the pipeline's current default version. + // We also eagerly fetch the default version's name here — it's needed later to derive the + // auto-generated name if a new draft version is created by the server. + VersionTarget target = resolveVersionTarget(pipe, wspId); + + // Fetch the existing launch configuration for the target version so we can merge the user's + // CLI overrides on top of the current values (coalesce pattern: user value wins, else keep existing). + LaunchDbDto launch = fetchLaunch(pipe, wspId, target.versionId); + + // Resolve the compute environment: use --compute-env if provided, otherwise keep whatever + // the launch already references. This needs a separate API call when the user specifies a + // CE by name (to look up its ID). + String ceId = resolveComputeEnvId(wspId, launch); + + // Build the update payload merging CLI flags with existing launch values. + UpdatePipelineRequest updateReq = buildUpdateRequest(pipe, launch, ceId); + + // Send the update to the versioned endpoint (POST /pipelines/{id}/versions/{vid}). + // If versionable fields (revision, pipeline URL, etc.) changed, the server may create a + // new draft version instead of updating the existing one in-place. + UpdatePipelineResponse response = pipelineVersionsApi() + .updatePipelineVersion(pipe.getPipelineId(), target.versionId, updateReq, wspId); + + // Detect whether a new version was created and handle it: + // - Server already named it (fixed API) → just report it. + // - Unnamed draft + --allow-draft → report draft ID, let user manage it manually. + // - Unnamed draft (default behavior) → auto-generate a name, assign it, and promote to default + // (mirrors the frontend auto-naming algorithm). + return handleVersioningResult(response, target, pipe, wspId); + } + + // --- Pipeline resolution --- + + private PipelineDbDto fetchPipeline(Long wspId) throws ApiException { + // Always go through describePipeline (not just the search/list endpoint) because + // describePipeline returns the default version on pipe.getVersion() — we need it + // later for auto-naming. The parent's fetchPipeline resolves name→id first if needed. + return fetchPipeline(pipelineRefOptions, wspId); + } + + private void validateNewName(String newName, Long wspId) throws ApiException { + if (newName == null) return; Long orgId = wspId != null ? orgId(wspId) : null; - PipelineDbDto pipe; - Long id; - - if (pipelineRefOptions.pipeline.pipelineId != null) { - id = pipelineRefOptions.pipeline.pipelineId; - pipe = pipelinesApi().describePipeline(id, Collections.emptyList(), wspId, null).getPipeline(); - } else { - pipe = pipelineByName(wspId, pipelineRefOptions.pipeline.pipelineName); - id = pipe.getPipelineId(); + try { + pipelinesApi().validatePipelineName(wspId, orgId, newName); + } catch (ApiException ex) { + throw new InvalidResponseException(String.format("Pipeline name '%s' is not valid", newName)); } + } - if (newName != null) { - try { - pipelinesApi().validatePipelineName(wspId, orgId, newName); - } catch (ApiException ex) { - throw new InvalidResponseException(String.format("Pipeline name '%s' is not valid", newName)); - } + // --- Version resolution --- + + /** + * Carries the resolved version ID to update and the current default version's name. + * The default version name is needed later by autoNameAndPromote to derive the next + * incremental name (e.g. "pipeline-3" → "pipeline-4"). + */ + private static class VersionTarget { + final String versionId; + final String defaultVersionName; + + VersionTarget(String versionId, String defaultVersionName) { + this.versionId = versionId; + this.defaultVersionName = defaultVersionName; + } + } + + private VersionTarget resolveVersionTarget(PipelineDbDto pipe, Long wspId) throws ApiException { + // describePipeline (called without a versionId) already returns the default version + // on pipe.getVersion() — no need for a separate listPipelineVersions call. + // We need the default version's name for auto-naming if a draft is created later. + PipelineVersionFullInfoDto defaultVersion = pipe.getVersion(); + String defaultVersionName = defaultVersion.getName(); + + if (versionRef != null) { + // User explicitly targeted a version via --version-id or --version-name + String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); + return new VersionTarget(versionId, defaultVersionName); } - Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipe); - LaunchDbDto launch = pipelinesApi().describePipelineLaunch(id, wspId, sourceWorkspaceId, null).getLaunch(); - // Retrieve the provided computeEnv or use the primary if not provided - String ceId = null; + // No explicit version — target the default version + return new VersionTarget(defaultVersion.getId(), defaultVersionName); + } + + // --- Launch and request building --- + + private LaunchDbDto fetchLaunch(PipelineDbDto pipe, Long wspId, String versionId) throws ApiException { + // sourceWorkspaceId handles shared pipelines — the launch config lives in the source + // workspace, not necessarily the workspace where the user is operating. + Long sourceWspId = sourceWorkspaceId(wspId, pipe); + return pipelinesApi().describePipelineLaunch(pipe.getPipelineId(), wspId, sourceWspId, versionId).getLaunch(); + } + + private String resolveComputeEnvId(Long wspId, LaunchDbDto launch) throws ApiException { + // User explicitly picked a compute environment — resolve its ID by name or ID ref. if (opts.computeEnv != null) { - ceId = computeEnvByRef(wspId, opts.computeEnv).getId(); - } else { - final var ce = launch.getComputeEnv(); - if (ce != null) { - ceId = ce.getId(); - } + return computeEnvByRef(wspId, opts.computeEnv).getId(); } + // Keep the existing CE from the launch; may be null for shared workspace pipelines + // that don't have a CE pinned. + var ce = launch.getComputeEnv(); + return ce != null ? ce.getId() : null; + } - UpdatePipelineRequest updateReq = new UpdatePipelineRequest() + /** + * Merges CLI-provided values with the existing launch configuration. + * Each field uses coalesce(userValue, existingValue): the user's flag wins if provided, + * otherwise we preserve the current server-side value. This lets users update individual + * fields without having to re-specify everything. + */ + private UpdatePipelineRequest buildUpdateRequest(PipelineDbDto pipe, LaunchDbDto launch, String ceId) throws IOException { + return new UpdatePipelineRequest() .name(coalesce(newName, pipe.getName())) .description(coalesce(description, pipe.getDescription())) .launch(new WorkflowLaunchRequest() @@ -112,8 +210,6 @@ protected Response exec() throws ApiException, IOException { .workDir(coalesce(opts.workDir, launch.getWorkDir())) .configProfiles(coalesce(opts.profile, launch.getConfigProfiles())) .paramsText(coalesce(FilesHelper.readString(opts.paramsFile), launch.getParamsText())) - - // Advanced options .configText(coalesce(FilesHelper.readString(opts.config), launch.getConfigText())) .preRunScript(coalesce(FilesHelper.readString(opts.preRunScript), launch.getPreRunScript())) .postRunScript(coalesce(FilesHelper.readString(opts.postRunScript), launch.getPostRunScript())) @@ -126,9 +222,78 @@ protected Response exec() throws ApiException, IOException { .userSecrets(coalesce(removeEmptyValues(opts.userSecrets), launch.getUserSecrets())) .workspaceSecrets(coalesce(removeEmptyValues(opts.workspaceSecrets), launch.getWorkspaceSecrets())) ); + } + + // --- Post-update version handling --- + // + // When versionable fields (revision, pipeline URL, etc.) change, the server creates a new + // version instead of modifying the existing one in-place. + // The new version is an unnamed draft (name=null, isDefault=false). + // We must auto-generate a name, assign it, and promote it to default so it appears in the + // Launchpad — unless the user passed --allow-draft to keep it as a draft. + // + // If no versionable fields changed, the server updates the version in-place and returns the + // same version ID — detectNewVersion returns null and we skip all of this. - pipelinesApi().updatePipeline(pipe.getPipelineId(), updateReq, wspId); + private Response handleVersioningResult( + UpdatePipelineResponse response, VersionTarget target, PipelineDbDto pipe, Long wspId + ) throws ApiException { + PipelineVersionFullInfoDto newVersion = detectNewVersion(response, target.versionId); - return new PipelinesUpdated(workspaceRef(wspId), pipe.getName()); + String newVersionName = null; + String draftVersionId = null; + + if (newVersion != null) { + if (newVersion.getName() != null) { + // Fixed API path: server already named the version and promoted it to default. + newVersionName = newVersion.getName(); + } else if (allowDraft) { + // User explicitly opted to keep the draft as-is for manual management via + // 'tw pipelines versions'. + draftVersionId = newVersion.getId(); + } else { + // Current API path: unnamed draft. Auto-name it following the frontend convention + // (e.g. "pipeline-3" → "pipeline-4") and promote it to default so it's immediately + // available in the Launchpad. + newVersionName = autoNameAndPromote(newVersion, target, pipe, wspId); + } + } + + return new PipelinesUpdated(workspaceRef(wspId), pipe.getName(), newVersionName, draftVersionId); } + + /** + * Names an unnamed draft version and promotes it to default. This mirrors the frontend's + * behavior: derive an incremental name from the current default + * version name, validate it against the server (to avoid collisions), and assign it. + */ + private String autoNameAndPromote( + PipelineVersionFullInfoDto newVersion, VersionTarget target, PipelineDbDto pipe, Long wspId + ) throws ApiException { + String versionName = VersionNameHelper.generateValidVersionName( + target.defaultVersionName, pipe.getPipelineId(), wspId, pipelineVersionsApi() + ); + + pipelineVersionsApi().managePipelineVersion( + pipe.getPipelineId(), + newVersion.getId(), + new PipelineVersionManageRequest().name(versionName).isDefault(true), + wspId + ); + + return versionName; + } + + /** + * Detects whether the server created a new version as a side effect of the update. + * If the response contains a version with a different ID than the one we sent the update to, + * that's a newly created version (either named or draft). Same ID means in-place update. + */ + private PipelineVersionFullInfoDto detectNewVersion(UpdatePipelineResponse response, String requestedVersionId) { + if (response == null || response.getPipeline() == null) return null; + PipelineVersionFullInfoDto version = response.getPipeline().getVersion(); + if (version == null) return null; + return version.getId().equals(requestedVersionId) ? null : version; + } + } diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/ViewCmd.java index 314cc0f3..753485ce 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/ViewCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/ViewCmd.java @@ -18,11 +18,14 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesView; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineQueryAttribute; +import io.seqera.tower.model.PipelineVersionFullInfoDto; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -38,12 +41,36 @@ public class ViewCmd extends AbstractPipelinesCmd { @CommandLine.Mixin public WorkspaceOptionalOptions workspace; + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. + @CommandLine.ArgGroup(multiplicity = "0..1") + public VersionRefOptions.VersionRef versionRef; + @Override protected Response exec() throws ApiException { Long wspId = workspaceId(workspace.workspace); PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId, PipelineQueryAttribute.labels); Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipeline); - LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, null).getLaunch(); - return new PipelinesView(workspaceRef(wspId), pipeline, launch, baseWorkspaceUrl(wspId)); + + PipelineVersionFullInfoDto version; + String versionId; + if (versionRef != null) { + // User explicitly targeted a version via --version-id or --version-name + version = findPipelineVersionByRef(pipeline.getPipelineId(), wspId, versionRef); + if (version != null) { + versionId = version.getId(); + } else if (versionRef.versionId != null) { + // Pass the ID through even if not found in the list (let the API handle it) + versionId = versionRef.versionId; + } else { + throw new TowerException(String.format("Pipeline version '%s' not found", versionRef.versionName)); + } + } else { + // No explicit version — describePipeline already returns the default version + version = pipeline.getVersion(); + versionId = version.getId(); + } + + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); + return new PipelinesView(workspaceRef(wspId), pipeline, launch, version, baseWorkspaceUrl(wspId)); } } diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/LabelsCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/labels/LabelsCmd.java similarity index 88% rename from src/main/java/io/seqera/tower/cli/commands/pipelines/LabelsCmd.java rename to src/main/java/io/seqera/tower/cli/commands/pipelines/labels/LabelsCmd.java index 5fead943..7b28f614 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/LabelsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/labels/LabelsCmd.java @@ -14,10 +14,12 @@ * limitations under the License. */ -package io.seqera.tower.cli.commands.pipelines; +package io.seqera.tower.cli.commands.pipelines.labels; import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.labels.LabelsSubcmdOptions; +import io.seqera.tower.cli.commands.pipelines.AbstractPipelinesCmd; +import io.seqera.tower.cli.commands.pipelines.PipelineRefOptions; import io.seqera.tower.cli.responses.Response; import picocli.CommandLine; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/PipelinesLabelsManager.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/labels/PipelinesLabelsManager.java similarity index 97% rename from src/main/java/io/seqera/tower/cli/commands/pipelines/PipelinesLabelsManager.java rename to src/main/java/io/seqera/tower/cli/commands/pipelines/labels/PipelinesLabelsManager.java index 6f4a922b..b04667cf 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/PipelinesLabelsManager.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/labels/PipelinesLabelsManager.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.seqera.tower.cli.commands.pipelines; +package io.seqera.tower.cli.commands.pipelines.labels; import java.util.List; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ListCmd.java new file mode 100644 index 00000000..4c714b56 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ListCmd.java @@ -0,0 +1,86 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.commands.pipelines.versions; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.PaginationOptions; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.AbstractPipelinesCmd; +import io.seqera.tower.cli.commands.pipelines.PipelineRefOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.pipelines.versions.ListPipelineVersionsCmdResponse; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.ListPipelineVersionsResponse; +import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionFullInfoDto; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Command( + name = "list", + description = "List pipeline versions" +) +public class ListCmd extends AbstractPipelinesCmd { + + @CommandLine.Mixin + PipelineRefOptions pipelineRefOptions; + + @CommandLine.Mixin + WorkspaceOptionalOptions workspaceOptions; + + @CommandLine.Option(names = {"-f", "--filter"}, description = "Search pipeline versions by name prefix. Also supports keyword filters: versionName, versionId, versionHash. Multiple filters can be combined e.g. 'myPipeline versionName: versionHash:'.") + public String filter; + + @CommandLine.Option(names = {"--is-published"}, description = "Show only published pipeline versions if true, draft versions only if false, all versions by default") + Boolean isPublishedOption = null; + + @CommandLine.Option(names = {"--full-hash"}, description = "Show full-length hash values without truncation") + public boolean showFullHash; + + @CommandLine.Mixin + PaginationOptions paginationOptions; + + @Override + protected Response exec() throws ApiException { + + Long wspId = workspaceId(workspaceOptions.workspace); + PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); + + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + ListPipelineVersionsResponse response = pipelineVersionsApi().listPipelineVersions( + pipeline.getPipelineId(), + wspId, + max, offset, + filter, + isPublishedOption + ); + + List versions = response.getVersions().stream() + .map(PipelineDbDto::getVersion) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return new ListPipelineVersionsCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), versions, PaginationInfo.from(offset, max), showFullHash); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java new file mode 100644 index 00000000..1bdcfff8 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.commands.pipelines.versions; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.commands.pipelines.AbstractPipelinesCmd; +import io.seqera.tower.cli.commands.pipelines.PipelineRefOptions; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.pipelines.versions.ManagePipelineVersionCmdResponse; +import io.seqera.tower.cli.utils.ResponseHelper; +import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionManageRequest; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "manage", + description = "Manage a pipeline version name or default version status" +) +public class ManageCmd extends AbstractPipelinesCmd { + + @CommandLine.Mixin + PipelineRefOptions pipelineRefOptions; + + @CommandLine.Mixin + WorkspaceOptionalOptions workspaceOptions; + + @CommandLine.Mixin + VersionRefOptions versionRefOptions; + + @CommandLine.ArgGroup(exclusive = false, multiplicity = "1", + heading = "%nManage options (at least one required):%n") + public ManageOptions manageOptions; + + public static class ManageOptions { + @CommandLine.Option(names = {"--new-name"}, description = "New name for the pipeline version") + public String name; + + @CommandLine.Option(names = {"--set-default"}, description = "Set this version as the default") + public Boolean isDefault; + } + + @Override + protected Response exec() throws ApiException { + + Long wspId = workspaceId(workspaceOptions.workspace); + PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); + + String resolvedVersionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); + + PipelineVersionManageRequest request = new PipelineVersionManageRequest() + .name(manageOptions.name) + .isDefault(manageOptions.isDefault); + + try { + pipelineVersionsApi().managePipelineVersion( + pipeline.getPipelineId(), + resolvedVersionId, + request, + wspId + ); + } catch (ApiException e) { + throw new TowerException( + String.format("Unable to manage pipeline version '%s': %s", resolvedVersionId, ResponseHelper.decodeMessage(e)) + ); + } + + return new ManagePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), resolvedVersionId); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionRefOptions.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionRefOptions.java new file mode 100644 index 00000000..85092cd6 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionRefOptions.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.commands.pipelines.versions; + +import picocli.CommandLine; + +public class VersionRefOptions { + + @CommandLine.ArgGroup(multiplicity = "1") + public VersionRef versionRef; + + public static class VersionRef { + + @CommandLine.Option(names = {"--version-id"}, description = "Pipeline version identifier") + public String versionId; + + @CommandLine.Option(names = {"--version-name"}, description = "Pipeline version name") + public String versionName; + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionsCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionsCmd.java new file mode 100644 index 00000000..d75b3120 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionsCmd.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.commands.pipelines.versions; + +import io.seqera.tower.cli.commands.AbstractRootCmd; +import picocli.CommandLine; + + +@CommandLine.Command( + name = "versions", + description = "Manage pipeline versions", + subcommands = { + ListCmd.class, + ManageCmd.class, + } +) +public class VersionsCmd extends AbstractRootCmd { +} diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesUpdated.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesUpdated.java index a560f254..a245dc18 100644 --- a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesUpdated.java +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesUpdated.java @@ -17,19 +17,40 @@ package io.seqera.tower.cli.responses.pipelines; import io.seqera.tower.cli.responses.Response; +import jakarta.annotation.Nullable; public class PipelinesUpdated extends Response { public final String workspaceRef; public final String pipelineName; + @Nullable + public final String newVersionName; + @Nullable + public final String draftVersionId; public PipelinesUpdated(String workspaceRef, String pipelineName) { + this(workspaceRef, pipelineName, null, null); + } + + public PipelinesUpdated(String workspaceRef, String pipelineName, @Nullable String newVersionName) { + this(workspaceRef, pipelineName, newVersionName, null); + } + + public PipelinesUpdated(String workspaceRef, String pipelineName, @Nullable String newVersionName, @Nullable String draftVersionId) { this.workspaceRef = workspaceRef; this.pipelineName = pipelineName; + this.newVersionName = newVersionName; + this.draftVersionId = draftVersionId; } @Override public String toString() { - return ansi(String.format("%n @|yellow Pipeline '%s' updated at %s workspace|@%n", pipelineName, workspaceRef)); + String msg = String.format("%n @|yellow Pipeline '%s' updated at %s workspace|@", pipelineName, workspaceRef); + if (newVersionName != null) { + msg += String.format("%n @|yellow New version '%s' created and set as default, available in the Launchpad.|@", newVersionName); + } else if (draftVersionId != null) { + msg += String.format("%n @|yellow New draft version '%s' created. Use 'tw pipelines versions' to manage it.|@", draftVersionId); + } + return ansi(msg + String.format("%n")); } } diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesView.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesView.java index 4160a891..bcd1eb0e 100644 --- a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesView.java +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesView.java @@ -24,6 +24,7 @@ import io.seqera.tower.cli.utils.TableList; import io.seqera.tower.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.WorkflowLaunchRequest; import java.io.PrintWriter; @@ -36,14 +37,16 @@ public class PipelinesView extends Response { public final String workspaceRef; public final PipelineDbDto info; public final LaunchDbDto launch; + public final PipelineVersionFullInfoDto version; @JsonIgnore private final String baseWorkspaceUrl; - public PipelinesView(String workspaceRef, PipelineDbDto info, LaunchDbDto launch, String baseWorkspaceUrl) { + public PipelinesView(String workspaceRef, PipelineDbDto info, LaunchDbDto launch, PipelineVersionFullInfoDto version, String baseWorkspaceUrl) { this.workspaceRef = workspaceRef; this.info = info; this.launch = launch; + this.version = version; this.baseWorkspaceUrl = baseWorkspaceUrl; } @@ -66,6 +69,9 @@ public void toString(PrintWriter out) { table.addRow("Repository", info.getRepository()); table.addRow("Compute env.", launch.getComputeEnv() == null ? "(not defined)" : launch.getComputeEnv().getName()); table.addRow("Labels", info.getLabels() == null || info.getLabels().isEmpty() ? "No labels found" : formatLabels(info.getLabels())); + table.addRow("Version Name", version.getName() != null ? version.getName() : "(draft)"); + table.addRow("Version Is Default", version.getIsDefault() != null && version.getIsDefault() ? "yes" : "no"); + table.addRow("Version Hash", version.getHash()); table.print(); out.println(String.format("%n Configuration:%n%n%s%n", configJson.replaceAll("(?m)^", " "))); diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ListPipelineVersionsCmdResponse.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ListPipelineVersionsCmdResponse.java new file mode 100644 index 00000000..5055f573 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ListPipelineVersionsCmdResponse.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.responses.pipelines.versions; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.FormatHelper; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.PipelineVersionFullInfoDto; +import jakarta.annotation.Nullable; + +import java.io.PrintWriter; +import java.util.Comparator; +import java.util.List; + +public class ListPipelineVersionsCmdResponse extends Response { + + public final String workspaceRef; + public final Long pipelineId; + public final String pipelineName; + public final List versions; + + @JsonIgnore + @Nullable + private PaginationInfo paginationInfo; + + @JsonIgnore + private boolean showFullHash; + + public ListPipelineVersionsCmdResponse(String workspaceRef, Long pipelineId, String pipelineName, List versions, @Nullable PaginationInfo paginationInfo, boolean showFullHash) { + this.workspaceRef = workspaceRef; + this.pipelineId = pipelineId; + this.pipelineName = pipelineName; + this.versions = versions; + this.paginationInfo = paginationInfo; + this.showFullHash = showFullHash; + } + + @Override + public void toString(PrintWriter out) { + + if (workspaceRef != null) { + out.println(ansi(String.format("%n @|bold Pipeline versions of '%s' in workspace %s :|@%n", pipelineName, workspaceRef))); + } else { + out.println(ansi(String.format("%n @|bold Pipeline versions of '%s' in user workspace:|@%n", pipelineName))); + } + + if (versions.isEmpty()) { + out.println(ansi(" @|yellow No pipeline versions found|@")); + return; + } + + TableList table = new TableList(out, 6, "ID", "Name", "IsDefault", "Hash", "Creator", "Created At"); + + versions.stream() + .sorted(Comparator.comparing(PipelineVersionFullInfoDto::getDateCreated)) + .forEach(version -> table.addRow( + version.getId(), + version.getName(), + version.getIsDefault() ? "yes" : "no", + showFullHash ? version.getHash() : FormatHelper.formatLargeStringWithEllipsis(version.getHash(), 40), + version.getCreatorUserName(), + FormatHelper.formatTime(version.getDateCreated()) + )); + + table.print(); + + PaginationInfo.addFooter(out, paginationInfo); + + out.println(); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java new file mode 100644 index 00000000..b0f3b550 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.responses.pipelines.versions; + +import io.seqera.tower.cli.responses.Response; + +public class ManagePipelineVersionCmdResponse extends Response { + + public final String workspaceRef; + public final Long pipelineId; + public final String pipelineName; + public final String versionId; + + public ManagePipelineVersionCmdResponse(String workspaceRef, Long pipelineId, String pipelineName, String versionId) { + this.workspaceRef = workspaceRef; + this.pipelineId = pipelineId; + this.pipelineName = pipelineName; + this.versionId = versionId; + } + + @Override + public String toString() { + if (workspaceRef != null) { + return ansi(String.format("%n @|yellow Pipeline version '%s' of pipeline '%s' updated at workspace %s|@%n", versionId, pipelineName, workspaceRef)); + } + return ansi(String.format("%n @|yellow Pipeline version '%s' of pipeline '%s' updated|@%n", versionId, pipelineName)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java index 3eecd7ba..98bb555a 100644 --- a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java @@ -350,4 +350,17 @@ public static String formatDescription(String description, int maxLength) { return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text; } + public static String formatLargeStringWithEllipsis(String largeString, int maxLength) { + if (largeString == null) { + return "NA"; + } + if (largeString.length() <= maxLength) { + return largeString; + } + int remaining = maxLength - 3; // reserve space for "..." + int head = (remaining + 1) / 2; + int tail = remaining / 2; + return largeString.substring(0, head) + "..." + largeString.substring(largeString.length() - tail); + } + } diff --git a/src/main/java/io/seqera/tower/cli/utils/TableList.java b/src/main/java/io/seqera/tower/cli/utils/TableList.java index 815af4fc..50c26275 100644 --- a/src/main/java/io/seqera/tower/cli/utils/TableList.java +++ b/src/main/java/io/seqera/tower/cli/utils/TableList.java @@ -75,7 +75,7 @@ public TableList compareWith(Comparator c) { } public TableList sortBy(int column) { - return this.compareWith((o1, o2) -> o1[column].compareTo(o2[column])); + return this.compareWith(Comparator.comparing(o -> o[column])); } /** diff --git a/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java new file mode 100644 index 00000000..38e4d91c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java @@ -0,0 +1,111 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.utils; + +import io.seqera.tower.ApiException; +import io.seqera.tower.api.PipelineVersionsApi; + +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Port of the frontend version naming algorithm. + * + * Generates incremental version names by stripping a trailing -{number} suffix, + * incrementing it (or starting at 1), and validating with the server. + */ +public class VersionNameHelper { + + private static final int MAX_RETRIES = 10; + private static final long RETRY_DELAY_MS = 150; + private static final Pattern VERSION_SUFFIX = Pattern.compile("-(\\d+)$"); + + private VersionNameHelper() { + } + + /** + * Generates the next version name from a base name. + * Strips trailing -{number}, increments (or starts at 1). + * E.g., "pipeline-3" → "pipeline-4", "rnaseq" → "rnaseq-1" + */ + protected static String generateNextVersionName(String baseName) { + if (baseName == null || baseName.isEmpty()) { + return baseName; + } + + Matcher matcher = VERSION_SUFFIX.matcher(baseName); + if (matcher.find()) { + int currentVersion = Integer.parseInt(matcher.group(1)); + String prefix = baseName.substring(0, matcher.start()); + return prefix + "-" + (currentVersion + 1); + } + + return baseName + "-1"; + } + + /** + * Generates a valid version name using the naming algorithm with server-side validation. + * Tries up to 20 incremental names, then falls back to a random 4-char suffix. + */ + public static String generateValidVersionName( + String baseName, + Long pipelineId, + Long wspId, + PipelineVersionsApi api + ) throws ApiException { + if (baseName == null || baseName.isEmpty()) { + return generateRandomFallback(baseName != null ? baseName : ""); + } + + Matcher matcher = VERSION_SUFFIX.matcher(baseName); + int startIndex; + String prefix; + + if (matcher.find()) { + startIndex = Integer.parseInt(matcher.group(1)) + 1; + prefix = baseName.substring(0, matcher.start()); + } else { + startIndex = 1; + prefix = baseName; + } + + for (int i = startIndex; i < startIndex + MAX_RETRIES; i++) { + String candidate = prefix + "-" + i; + try { + api.validatePipelineVersionName(pipelineId, candidate, wspId); + return candidate; + } catch (ApiException e) { + // Validation failed (name already taken), try next + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + + return generateRandomFallback(prefix); + } + + private static String generateRandomFallback(String prefix) { + String random = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + return prefix + "-" + random; + } +} diff --git a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java index 6ce9dafe..a2adf9c0 100644 --- a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java @@ -22,6 +22,7 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.enums.OutputType; import io.seqera.tower.cli.exceptions.InvalidResponseException; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.runs.RunSubmited; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -588,4 +589,163 @@ void testSubmitLaunchpadPipelineWithOptimizationDisabled(OutputType format, Mock assertOutput(format, out, new RunSubmited("35aLiS0bIM5efd", null, baseUserUrl(mock, "jordi"), USER_WORKSPACE_NAME)); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testSubmitLaunchpadPipelineWithVersionId(OutputType format, MockServerClient mock) { + + // Create server expectation + mock.when( + request().withMethod("GET").withPath("/pipelines"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_sarek")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/250911634275687/launch") + .withQueryStringParameter("versionId", "ver789"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipeline_launch_describe")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/workflow/launch") + .withBody(json(""" + { + "launch":{ + "id":"5nmCvXcarkvv8tELMF4KyY", + "computeEnvId":"4X7YrYJp9B1d1DUpfur7DS", + "pipeline":"https://github.com/nf-core/sarek", + "workDir":"/efs", + "pullLatest":false, + "stubRun":false, + "optimizationId": "rOYdwTnmTaRCJjUq", + "optimizationTargets": "cpus, memory" + } + }""" + )), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workflow_launch")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Run the command + ExecOut out = exec(format, mock, "launch", "sarek", "--version-id", "ver789"); + + // Assert results + assertOutput(format, out, new RunSubmited("35aLiS0bIM5efd", null, baseUserUrl(mock, "jordi"), USER_WORKSPACE_NAME)); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testSubmitLaunchpadPipelineWithVersionName(OutputType format, MockServerClient mock) { + + // Create server expectation + mock.when( + request().withMethod("GET").withPath("/pipelines"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_sarek")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock version name resolution via versions list + mock.when( + request().withMethod("GET").withPath("/pipelines/250911634275687/versions") + .withQueryStringParameter("search", "release-1.0") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 250911634275687, + "name": "sarek", + "repository": "https://github.com/nf-core/sarek", + "userId": 1, + "userName": "user", + "version": { + "id": "resolvedVerId", + "name": "release-1.0", + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z", + "isDefault": false + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/250911634275687/launch") + .withQueryStringParameter("versionId", "resolvedVerId"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipeline_launch_describe")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/workflow/launch") + .withBody(json(""" + { + "launch":{ + "id":"5nmCvXcarkvv8tELMF4KyY", + "computeEnvId":"4X7YrYJp9B1d1DUpfur7DS", + "pipeline":"https://github.com/nf-core/sarek", + "workDir":"/efs", + "pullLatest":false, + "stubRun":false, + "optimizationId": "rOYdwTnmTaRCJjUq", + "optimizationTargets": "cpus, memory" + } + }""" + )), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("workflow_launch")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Run the command + ExecOut out = exec(format, mock, "launch", "sarek", "--version-name", "release-1.0"); + + // Assert results + assertOutput(format, out, new RunSubmited("35aLiS0bIM5efd", null, baseUserUrl(mock, "jordi"), USER_WORKSPACE_NAME)); + } + + @Test + void testSubmitLaunchpadPipelineWithVersionNameNotFound(MockServerClient mock) { + + mock.when( + request().withMethod("GET").withPath("/pipelines"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_sarek")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Version name resolution returns no matching versions + mock.when( + request().withMethod("GET").withPath("/pipelines/250911634275687/versions") + .withQueryStringParameter("search", "nonexistent") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [], + "totalSize": 0 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "launch", "sarek", "--version-name", "nonexistent"); + + assertEquals(errorMessage(out.app, new TowerException("Pipeline version 'nonexistent' not found")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + } diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java new file mode 100644 index 00000000..f38214f6 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -0,0 +1,450 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.pipelines; + +import io.seqera.tower.cli.BaseCmdTest; +import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.PipelineNotFoundException; +import io.seqera.tower.cli.responses.pipelines.versions.ListPipelineVersionsCmdResponse; +import io.seqera.tower.cli.responses.pipelines.versions.ManagePipelineVersionCmdResponse; +import io.seqera.tower.cli.utils.PaginationInfo; +import io.seqera.tower.model.PipelineVersionFullInfoDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.MediaType; + +import java.time.OffsetDateTime; +import java.util.List; + +import static io.seqera.tower.cli.commands.AbstractApiCmd.USER_WORKSPACE_NAME; +import static org.apache.commons.lang3.StringUtils.chop; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.matchers.Times.exactly; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +class PipelineVersionsCmdTest extends BaseCmdTest { + + private static final Long PIPELINE_ID = 188439584587120L; + private static final String PIPELINE_NAME = "TestVersioningInUserWsp"; + + private static final String HASH_V1 = "JHY1OjIzYjNmYmVkN2NhZTU4Y2U0NDk1ZjA2MDY4YWRlOTE2MzJlMWFkMjlhY2RkNjY0NDM0MzFlMzY3NGEzNTBmNWMyOTIxMjhhMjNiMDMxMWU2ZjY2MmY4OTQ2OGVjOTRlMGNjMDVkNThkYTc2OGE2ZjVhNDlmY2JhZjY3YjNjYzY1"; + private static final String HASH_V2 = "JHY1OjU1MmEyZDEzZDI1MjA1MjJlNzc4MjdkM2M3ZmM2ZjdiMzhhYmMwNWEwZjNjYWM4MjlmYjI3MzU0MjNkNWI5YWQyNWVmYWFjNjQyNjUzNWQ5OGNlOTA5MWY1OTI3Yzg1OTk4MzAyYWM2ZTk1MzNhYzJmMjQzNGJiZTBkNjQ3MTg1"; + private static final String HASH_DRAFT = "JHY1OjdlYmZmODY1MzUwMWRmNjJlMDc0YjIwNGY4MTExYTIwNzRmNTU2MzFjZjg4YTA1ODk1ZTAwMTM1NWUzMGQzZjZmOGQ4MGRhMTY5NTFmNTc3NWViMGYwYWYyZDM4NTBiYzZhZTcwODU3YTkyZWIyOGFiNjA2M2I4N2I4MWQ5MTlh"; + + private static final String VERSION_ID_V1 = "7TnlaOKANkiDIdDqOO2kCs"; + + private List allVersions() { + return List.of( + new PipelineVersionFullInfoDto() + .id("7TnlaOKANkiDIdDqOO2kCs") + .name("TestVersioningInUserWsp-1") + .hash(HASH_V1) + .isDefault(true) + .creatorUserName("jaime-munoz") + .creatorUserId(1L) + .dateCreated(OffsetDateTime.parse("2026-02-17T17:43:32Z")) + .lastUpdated(OffsetDateTime.parse("2026-02-17T17:43:32Z")), + new PipelineVersionFullInfoDto() + .id("a48GJwfXIUUPIakcwFeue") + .name("TestVersioningInUserWsp-2") + .hash(HASH_V2) + .isDefault(false) + .creatorUserName("jaime-munoz") + .creatorUserId(1L) + .dateCreated(OffsetDateTime.parse("2026-02-17T18:27:39Z")) + .lastUpdated(OffsetDateTime.parse("2026-02-17T18:27:39Z")), + new PipelineVersionFullInfoDto() + .id("7KtabH1PaW1IBPYUdzVcXh") + .name(null) + .hash(HASH_DRAFT) + .isDefault(false) + .creatorUserName("jaime-munoz") + .creatorUserId(1L) + .dateCreated(OffsetDateTime.parse("2026-02-17T18:28:01Z")) + .lastUpdated(OffsetDateTime.parse("2026-02-17T18:28:01Z")) + ); + } + + private List publishedVersions() { + return List.of( + new PipelineVersionFullInfoDto() + .id("7TnlaOKANkiDIdDqOO2kCs") + .name("TestVersioningInUserWsp-1") + .hash(HASH_V1) + .isDefault(true) + .creatorUserName("jaime-munoz") + .creatorUserId(1L) + .dateCreated(OffsetDateTime.parse("2026-02-17T17:43:32Z")) + .lastUpdated(OffsetDateTime.parse("2026-02-17T17:43:32Z")), + new PipelineVersionFullInfoDto() + .id("a48GJwfXIUUPIakcwFeue") + .name("TestVersioningInUserWsp-2") + .hash(HASH_V2) + .isDefault(false) + .creatorUserName("jaime-munoz") + .creatorUserId(1L) + .dateCreated(OffsetDateTime.parse("2026-02-17T18:27:39Z")) + .lastUpdated(OffsetDateTime.parse("2026-02-17T18:27:39Z")) + ); + } + + private void mockPipelineSearchByName(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"" + PIPELINE_NAME + "\"") + .withQueryStringParameter("visibility", "all"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/pipelines_search")) + .withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockPipelineDescribe(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/pipeline_describe")) + .withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockVersionsList(MockServerClient mock) { + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/versions_list")) + .withContentType(MediaType.APPLICATION_JSON) + ); + } + + private void mockManageVersion(MockServerClient mock, String expectedBody) { + mock.when( + request().withMethod("PUT").withPath("/pipelines/" + PIPELINE_ID + "/versions/" + VERSION_ID_V1 + "/manage") + .withBody(json(expectedBody)), + exactly(1) + ).respond( + response().withStatusCode(204) + ); + } + + // --- List command tests --- + + @ParameterizedTest + @EnumSource(OutputType.class) + void testListVersionsByName(OutputType format, MockServerClient mock) { + + mock.reset(); + mockPipelineSearchByName(mock); + mockPipelineDescribe(mock); + mockVersionsList(mock); + + ExecOut out = exec(format, mock, "pipelines", "versions", "list", "-n", PIPELINE_NAME); + + assertOutput(format, out, new ListPipelineVersionsCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, + allVersions(), PaginationInfo.from((Integer) null, (Integer) null), false + )); + } + + @Test + void testListVersionsByPipelineId(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockVersionsList(mock); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-i", PIPELINE_ID.toString()); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ListPipelineVersionsCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, + allVersions(), PaginationInfo.from((Integer) null, (Integer) null), false + ).toString()), out.stdOut); + } + + @Test + void testListVersionsEmpty(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody("{\"versions\":[],\"totalSize\":0}") + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-i", PIPELINE_ID.toString()); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ListPipelineVersionsCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, + List.of(), PaginationInfo.from((Integer) null, (Integer) null), false + ).toString()), out.stdOut); + } + + @Test + void testListVersionsWithFilter(MockServerClient mock) { + + mock.reset(); + mockPipelineSearchByName(mock); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") + .withQueryStringParameter("search", "TestVersioningInUserWsp-1"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/versions_published")) + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-n", PIPELINE_NAME, "-f", "TestVersioningInUserWsp-1"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testListVersionsWithMultipleFilterKeywords(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") + .withQueryStringParameter("search", "TestVersioningInUserWsp versionHash:" + HASH_V1), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/versions_published")) + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-i", PIPELINE_ID.toString(), + "-f", "TestVersioningInUserWsp versionHash:" + HASH_V1); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + + @Test + void testListVersionsWithPagination(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") + .withQueryStringParameter("max", "2") + .withQueryStringParameter("offset", "1"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/versions_published")) + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-i", PIPELINE_ID.toString(), "--offset", "1", "--max", "2"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ListPipelineVersionsCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, + publishedVersions(), PaginationInfo.from(1, 2), false + ).toString()), out.stdOut); + } + + @Test + void testListVersionsPipelineNotFound(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"nonexistent\"") + .withQueryStringParameter("visibility", "all"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody("{\"pipelines\":[],\"totalSize\":0}") + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-n", "nonexistent"); + + assertEquals(errorMessage(out.app, new PipelineNotFoundException("\"nonexistent\"", USER_WORKSPACE_NAME)), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testListVersionsWithFullHash(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockVersionsList(mock); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-i", PIPELINE_ID.toString(), "--full-hash"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ListPipelineVersionsCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, + allVersions(), PaginationInfo.from((Integer) null, (Integer) null), true + ).toString()), out.stdOut); + } + + // --- Manage command tests --- + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUpdateVersionName(OutputType format, MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockManageVersion(mock, "{\"name\":\"new-version-name\"}"); + + ExecOut out = exec(format, mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--new-name", "new-version-name"); + + assertOutput(format, out, new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1)); + } + + @Test + void testUpdateVersionSetDefault(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockManageVersion(mock, "{\"isDefault\":true}"); + + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--set-default"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + } + + @Test + void testUpdateVersionByPipelineName(MockServerClient mock) { + + mock.reset(); + mockPipelineSearchByName(mock); + mockPipelineDescribe(mock); + mockManageVersion(mock, "{\"name\":\"renamed-version\"}"); + + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-n", PIPELINE_NAME, + "--version-id", VERSION_ID_V1, "--new-name", "renamed-version"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + } + + @Test + void testUpdateVersionByVersionName(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + // Mock versions list endpoint for version name resolution + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") + .withQueryStringParameter("search", "TestVersioningInUserWsp-1") + .withQueryStringParameter("isPublished", "true"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/versions_published")) + .withContentType(MediaType.APPLICATION_JSON) + ); + + mockManageVersion(mock, "{\"name\":\"renamed\"}"); + + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), + "--version-name", "TestVersioningInUserWsp-1", "--new-name", "renamed"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + } + + @Test + void testUpdateVersionInvalidName(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("PUT").withPath("/pipelines/" + PIPELINE_ID + "/versions/" + VERSION_ID_V1 + "/manage") + .withBody(json("{\"name\":\"!invalid!\"}")), + exactly(1) + ).respond( + response().withStatusCode(400) + .withBody("{\"message\":\"Invalid pipeline version name: must match pattern [a-zA-Z\\\\d][-._a-zA-Z\\\\d]{1,108}\"}") + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--new-name", "!invalid!"); + + assertEquals(1, out.exitCode); + assertEquals("", out.stdOut); + } + + @Test + void testUpdateVersionPipelineNotFound(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"nonexistent\"") + .withQueryStringParameter("visibility", "all"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody("{\"pipelines\":[],\"totalSize\":0}") + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-n", "nonexistent", + "--version-id", VERSION_ID_V1, "--new-name", "new-name"); + + assertEquals(errorMessage(out.app, new PipelineNotFoundException("\"nonexistent\"", USER_WORKSPACE_NAME)), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } +} diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java index 787b9145..22713816 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -61,6 +61,7 @@ import static io.seqera.tower.cli.utils.JsonHelper.parseJson; import static org.apache.commons.lang3.StringUtils.chop; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; @@ -79,17 +80,38 @@ void testUpdate(MockServerClient mock) { response().withStatusCode(200).withBody("{\"pipelines\":[{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON) ); + // describePipeline returns the default version on pipe.getVersion() mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") .withBody(json("{\"description\":\"Sleep one minute and exit\",\"name\":\"sleep_one_minute\",\"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pipelineSchemaId\":56789,\"pullLatest\":false,\"stubRun\":false}}")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleep_one_minute","description":"Sleep one minute and exit","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-d", "Sleep one minute and exit"); @@ -110,7 +132,26 @@ void testUpdateComputeEnv(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); @@ -128,10 +169,11 @@ void testUpdateComputeEnv(MockServerClient mock) { ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") .withBody(json("{\"name\":\"sleep_one_minute\",\"launch\":{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-c", "demo"); @@ -152,24 +194,43 @@ void testUpdatePipelineName(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + request().withMethod("GET").withPath("/pipelines/217997727159863") ).respond( - response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") - .withBody(json("{\"name\": \"sleepOneMinute\", \"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), - exactly(1) + request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "sleepOneMinute") ).respond( - response().withStatusCode(200) - .withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleepOneMinute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(204) ); mock.when( - request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "sleepOneMinute") + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver") ).respond( - response().withStatusCode(204) + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") + .withBody(json("{\"name\": \"sleepOneMinute\", \"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleepOneMinute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--new-name", "sleepOneMinute"); @@ -187,67 +248,42 @@ void testUpdateWithCommitId(MockServerClient mock) { mock.when( request().withMethod("GET").withPath("/pipelines").withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) ).respond( - response().withStatusCode(200).withBody("{" + - "\"pipelines\":[{" + - "\"pipelineId\":217997727159863," + - "\"name\":\"sleep_one_minute\"," + - "\"description\":null," + - "\"icon\":null," + - "\"repository\":\"https://github.com/pditommaso/nf-sleep\"," + - "\"userId\":4," + - "\"userName\":\"jordi\"," + - "\"userFirstName\":null," + - "\"userLastName\":null," + - "\"orgId\":null," + - "\"orgName\":null," + - "\"workspaceId\":null," + - "\"workspaceName\":null," + - "\"visibility\":null" + - "}]," + - "\"totalSize\":1" + - "}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipelines":[{"pipelineId":217997727159863,"name":"sleep_one_minute","repository":"https://github.com/pditommaso/nf-sleep"}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") - .withBody(json("{" + - "\"name\":\"sleep_one_minute\"," + - "\"launch\":{" + - "\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\"," + - "\"pipeline\":\"https://github.com/pditommaso/nf-sleep\"," + - "\"commitId\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," + - "\"workDir\":\"s3://nextflow-ci/jordeu\"," + - "\"paramsText\":\"timeout: 60\\n\"," + - "\"pullLatest\":false," + - "\"stubRun\":false" + - "}" + - "}")), exactly(1) + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") + .withBody(json(""" + {"name":"sleep_one_minute","launch":{"computeEnvId":"vYOK4vn7spw7bHHWBDXZ2","pipeline":"https://github.com/pditommaso/nf-sleep","commitId":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","workDir":"s3://nextflow-ci/jordeu","paramsText":"timeout: 60\\n","pullLatest":false,"stubRun":false}}""")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{" + - "\"pipeline\":{" + - "\"pipelineId\":217997727159863," + - "\"name\":\"sleep_one_minute\"," + - "\"description\":null," + - "\"icon\":null," + - "\"repository\":\"https://github.com/pditommaso/nf-sleep\"," + - "\"commitId\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," + - "\"userId\":4," + - "\"userName\":\"jordi\"," + - "\"userFirstName\":null," + - "\"userLastName\":null," + - "\"orgId\":null," + - "\"orgName\":null," + - "\"workspaceId\":null," + - "\"workspaceName\":null," + - "\"visibility\":null" + - "}" + - "}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--commit-id", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); @@ -269,18 +305,21 @@ void testUpdatePipelineInvalidName(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch") - ).respond( - response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) - ); - - mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") - .withBody(json("{\"name\": \"sleepOneMinute\", \"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), - exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863") ).respond( - response().withStatusCode(200) - .withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleepOneMinute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -324,11 +363,13 @@ void testUpdateInSharedWorkspaceWithoutCE(MockServerClient mock) throws IOExcept ).respond( response().withStatusCode(200) .withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/nextflow-io/hello\",\"userId\":312,\"userName\":\"user\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":44406019030987,\"orgName\":\"test-cli-org\",\"workspaceId\":59563405657242,\"workspaceName\":\"SharedWS\",\"visibility\":\"SHARED\",\"deleted\":false,\"lastUpdated\":\"2024-11-12T13:27:32Z\",\"optimizationId\":null,\"optimizationTargets\":null,\"optimizationStatus\":null,\"labels\":null,\"computeEnv\":null}}\n") + .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/nextflow-io/hello\",\"userId\":312,\"userName\":\"user\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":44406019030987,\"orgName\":\"test-cli-org\",\"workspaceId\":59563405657242,\"workspaceName\":\"SharedWS\",\"visibility\":\"SHARED\",\"deleted\":false,\"lastUpdated\":\"2024-11-12T13:27:32Z\",\"optimizationId\":null,\"optimizationTargets\":null,\"optimizationStatus\":null,\"labels\":null,\"computeEnv\":null,\"version\":{\"id\":\"default-ver\",\"name\":\"hello-pipeline-1\",\"isDefault\":true}}}\n") ); mock.when( - request().withMethod("GET").withPath("/pipelines/68359275903286/launch").withQueryStringParameter("workspaceId","59563405657242") + request().withMethod("GET").withPath("/pipelines/68359275903286/launch") + .withQueryStringParameter("workspaceId","59563405657242") + .withQueryStringParameter("versionId", "default-ver") ).respond( response().withStatusCode(200) .withContentType(MediaType.APPLICATION_JSON) @@ -336,14 +377,15 @@ void testUpdateInSharedWorkspaceWithoutCE(MockServerClient mock) throws IOExcept ); mock.when( - request().withMethod("PUT").withPath("/pipelines/68359275903286").withQueryStringParameter("workspaceId","59563405657242") + request().withMethod("POST").withPath("/pipelines/68359275903286/versions/default-ver") + .withQueryStringParameter("workspaceId","59563405657242") .withContentType(MediaType.APPLICATION_JSON) .withBody("{\"name\":\"hello-pipeline\",\"launch\":{\"pipeline\":\"https://github.com/nextflow-io/hello\",\"revision\":\"master\",\"preRunScript\":\"yyy\",\"pullLatest\":false,\"stubRun\":false}}"), exactly(1) ).respond( response().withStatusCode(200) .withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/nextflow-io/hello\",\"userId\":312,\"userName\":\"andrea-tortorella\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":44406019030987,\"orgName\":\"test-cli-org\",\"workspaceId\":59563405657242,\"workspaceName\":\"SharedWS\",\"visibility\":\"SHARED\",\"deleted\":false,\"lastUpdated\":\"2024-11-12T13:34:06.403300434Z\",\"optimizationId\":null,\"optimizationTargets\":null,\"optimizationStatus\":null,\"labels\":null,\"computeEnv\":null}}") + .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"version\":{\"id\":\"default-ver\",\"name\":\"hello-pipeline-1\",\"isDefault\":true}}}") ); ExecOut out = exec(mock,"pipelines", "update", "--id", "68359275903286", "--workspace", "59563405657242", "--pre-run", tempFile("yyy","pre-run","txt")); @@ -410,6 +452,74 @@ void testAdd(MockServerClient mock) throws IOException { } + @Test + void testAddWithLabelsFailure(MockServerClient mock) throws IOException { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"message\":null,\"lastUsed\":null,\"primary\":true,\"workspaceName\":null,\"visibility\":null}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_env_demo")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{" + + "\"pipeline\":{" + + "\"pipelineId\":18388134856008," + + "\"name\":\"sleep_one_minute\"," + + "\"description\":null," + + "\"icon\":null," + + "\"repository\":\"https://github.com/pditommaso/nf-sleep\"," + + "\"userId\":4," + + "\"userName\":\"jordi\"," + + "\"userFirstName\":null," + + "\"userLastName\":null," + + "\"orgId\":null," + + "\"orgName\":null," + + "\"workspaceId\":null," + + "\"workspaceName\":null," + + "\"visibility\":null" + + "}" + + "}").withContentType(MediaType.APPLICATION_JSON) + ); + + // Label search succeeds + mock.when( + request().withMethod("GET").withPath("/labels") + .withQueryStringParameter("type", "simple") + .withQueryStringParameter("search", "bad_label"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"labels\":[{\"id\":99999,\"name\":\"bad_label\",\"value\":null,\"resource\":false,\"isDefault\":false}],\"totalSize\":1}") + ); + + // Label apply fails + mock.when( + request().withMethod("POST").withPath("/pipelines/labels/apply"), exactly(1) + ).respond( + response().withStatusCode(400) + .withBody("{\"message\":\"Labels not found\"}") + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "--labels", "bad_label", "https://github.com/pditommaso/nf-sleep"); + + assertEquals(errorMessage(out.app, new TowerException("Pipeline 'sleep_one_minute' was created but failed to add labels: Labels not found")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + @Test void testAddWithCommitId(MockServerClient mock) throws IOException { @@ -480,14 +590,26 @@ void testAddWithCommitId(MockServerClient mock) throws IOException { } @Test - void testAddWithComputeEnv(MockServerClient mock) { + void testAddWithVersionName(MockServerClient mock) throws IOException { mock.reset(); mock.when( request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"message\":null,\"lastUsed\":null,\"primary\":null,\"workspaceName\":null,\"visibility\":null}]}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody("{" + + "\"computeEnvs\":[{" + + "\"id\":\"vYOK4vn7spw7bHHWBDXZ2\"," + + "\"name\":\"demo\"," + + "\"platform\":\"aws-batch\"," + + "\"status\":\"AVAILABLE\"," + + "\"message\":null," + + "\"lastUsed\":null," + + "\"primary\":true," + + "\"workspaceName\":null," + + "\"visibility\":null" + + "}]" + + "}").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -497,28 +619,65 @@ void testAddWithComputeEnv(MockServerClient mock) { ); mock.when( - request().withMethod("POST").withPath("/pipelines").withBody("{\"name\":\"demo\",\"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\"}}"), exactly(1) + request().withMethod("POST").withPath("/pipelines") + .withBody(json("{" + + "\"name\":\"sleep_one_minute\"," + + "\"version\":{\"name\":\"v1.0\"}," + + "\"launch\":{" + + "\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\"," + + "\"pipeline\":\"https://github.com/pditommaso/nf-sleep\"," + + "\"workDir\":\"s3://nextflow-ci/jordeu\"" + + "}" + + "}")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":18388134856008,\"name\":\"demo\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody("{" + + "\"pipeline\":{" + + "\"pipelineId\":18388134856008," + + "\"name\":\"sleep_one_minute\"," + + "\"description\":null," + + "\"icon\":null," + + "\"repository\":\"https://github.com/pditommaso/nf-sleep\"," + + "\"userId\":4," + + "\"userName\":\"jordi\"," + + "\"userFirstName\":null," + + "\"userLastName\":null," + + "\"orgId\":null," + + "\"orgName\":null," + + "\"workspaceId\":null," + + "\"workspaceName\":null," + + "\"visibility\":null" + + "}" + + "}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "add", "-n", "demo", "-c", "demo", "https://github.com/pditommaso/nf-sleep"); + ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "--version-name", "v1.0", "https://github.com/pditommaso/nf-sleep"); assertEquals("", out.stdErr); - assertEquals(new PipelinesAdded(USER_WORKSPACE_NAME, "demo").toString(), out.stdOut); + assertEquals(new PipelinesAdded(USER_WORKSPACE_NAME, "sleep_one_minute").toString(), out.stdOut); assertEquals(0, out.exitCode); - } @Test - void testAddWithStagingScripts(MockServerClient mock) throws IOException { + void testAddWithInvalidVersionName(MockServerClient mock) throws IOException { mock.reset(); mock.when( request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"message\":null,\"lastUsed\":null,\"primary\":true,\"workspaceName\":null,\"visibility\":null}]}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody("{" + + "\"computeEnvs\":[{" + + "\"id\":\"vYOK4vn7spw7bHHWBDXZ2\"," + + "\"name\":\"demo\"," + + "\"platform\":\"aws-batch\"," + + "\"status\":\"AVAILABLE\"," + + "\"message\":null," + + "\"lastUsed\":null," + + "\"primary\":true," + + "\"workspaceName\":null," + + "\"visibility\":null" + + "}]" + + "}").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -528,37 +687,109 @@ void testAddWithStagingScripts(MockServerClient mock) throws IOException { ); mock.when( - request().withMethod("POST").withPath("/pipelines").withBody("{\"name\":\"staging\",\"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/staging\",\"preRunScript\":\"pre_run_this\",\"postRunScript\":\"post_run_this\"}}"), exactly(1) + request().withMethod("POST").withPath("/pipelines") + .withBody(json("{" + + "\"name\":\"sleep_one_minute\"," + + "\"version\":{\"name\":\"!invalid!\"}," + + "\"launch\":{" + + "\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\"," + + "\"pipeline\":\"https://github.com/pditommaso/nf-sleep\"," + + "\"workDir\":\"s3://nextflow-ci/jordeu\"" + + "}" + + "}")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":21697594587521,\"name\":\"staging\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(400) + .withBody("{\"message\":\"Invalid pipeline version name: must match pattern [a-zA-Z\\\\d][-._a-zA-Z\\\\d]{1,108}\"}") + .withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "add", "-n", "staging", "--work-dir", "s3://nextflow-ci/staging", "--pre-run", tempFile("pre_run_this", "pre", "sh"), "--post-run", tempFile("post_run_this", "post", "sh"), "https://github.com/pditommaso/nf-sleep"); - - assertEquals("", out.stdErr); - assertEquals(new PipelinesAdded(USER_WORKSPACE_NAME, "staging").toString(), out.stdOut); - assertEquals(0, out.exitCode); + ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "--version-name", "!invalid!", "https://github.com/pditommaso/nf-sleep"); + assertEquals(errorMessage(out.app, new TowerException("Unable to add pipeline 'sleep_one_minute': Invalid pipeline version name: must match pattern [a-zA-Z\\d][-._a-zA-Z\\d]{1,108}")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); } @Test - void testMissingComputeEnvironment(MockServerClient mock) { + void testAddWithComputeEnv(MockServerClient mock) { mock.reset(); mock.when( request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"computeEnvs\":[]}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"message\":null,\"lastUsed\":null,\"primary\":null,\"workspaceName\":null,\"visibility\":null}]}").withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "https://github.com/pditommaso/nf-sleep"); + mock.when( + request().withMethod("GET").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_env_demo")).withContentType(MediaType.APPLICATION_JSON) + ); - assertEquals(errorMessage(out.app, new NoComputeEnvironmentException(USER_WORKSPACE_NAME)), out.stdErr); - assertEquals("", out.stdOut); - assertEquals(1, out.exitCode); + mock.when( + request().withMethod("POST").withPath("/pipelines").withBody("{\"name\":\"demo\",\"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\"}}"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":18388134856008,\"name\":\"demo\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + ); - } + ExecOut out = exec(mock, "pipelines", "add", "-n", "demo", "-c", "demo", "https://github.com/pditommaso/nf-sleep"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesAdded(USER_WORKSPACE_NAME, "demo").toString(), out.stdOut); + assertEquals(0, out.exitCode); + + } + + @Test + void testAddWithStagingScripts(MockServerClient mock) throws IOException { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[{\"id\":\"vYOK4vn7spw7bHHWBDXZ2\",\"name\":\"demo\",\"platform\":\"aws-batch\",\"status\":\"AVAILABLE\",\"message\":null,\"lastUsed\":null,\"primary\":true,\"workspaceName\":null,\"visibility\":null}]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/compute-envs/vYOK4vn7spw7bHHWBDXZ2"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("compute_env_demo")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines").withBody("{\"name\":\"staging\",\"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/staging\",\"preRunScript\":\"pre_run_this\",\"postRunScript\":\"post_run_this\"}}"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":21697594587521,\"name\":\"staging\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "add", "-n", "staging", "--work-dir", "s3://nextflow-ci/staging", "--pre-run", tempFile("pre_run_this", "pre", "sh"), "--post-run", tempFile("post_run_this", "post", "sh"), "https://github.com/pditommaso/nf-sleep"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesAdded(USER_WORKSPACE_NAME, "staging").toString(), out.stdOut); + assertEquals(0, out.exitCode); + + } + + @Test + void testMissingComputeEnvironment(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/compute-envs").withQueryStringParameter("status", "AVAILABLE"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"computeEnvs\":[]}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "add", "-n", "sleep_one_minute", "https://github.com/pditommaso/nf-sleep"); + + assertEquals(errorMessage(out.app, new NoComputeEnvironmentException(USER_WORKSPACE_NAME)), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + + } @Test void testDelete(MockServerClient mock) { @@ -770,10 +1001,29 @@ void testView(MockServerClient mock) throws JsonProcessingException { response().withStatusCode(200).withBody("{\"pipelines\":[{\"pipelineId\":213164477645856,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":1776,\"userName\":\"jordi10\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null,\"deleted\":false,\"lastUpdated\":\"2023-05-15T13:59:19Z\",\"optimized\":null,\"labels\":null,\"computeEnv\":null}],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON) ); + // describePipeline returns the default version — shown in output even without --version-id/--version-name mock.when( request().withMethod("GET").withPath("/pipelines/213164477645856").withQueryStringParameter("attributes", "labels"), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":213164477645856,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":1776,\"userName\":\"jordi10\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null,\"deleted\":false,\"lastUpdated\":\"2023-05-15T13:59:19Z\",\"optimized\":null,\"labels\":[],\"computeEnv\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "userId": 1776, + "userName": "jordi10", + "deleted": false, + "lastUpdated": "2023-05-15T13:59:19Z", + "labels": [], + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "hash": "abc123def456", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -788,31 +1038,15 @@ void testView(MockServerClient mock) throws JsonProcessingException { response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "view", "-n", "sleep_one_minute"); assertEquals("", out.stdErr); - assertEquals(StringUtils.chop(new PipelinesView( - USER_WORKSPACE_NAME, - new PipelineDbDto().pipelineId(213164477645856L).name("sleep_one_minute").repository("https://github.com/pditommaso/nf-sleep"), - new LaunchDbDto() - .id("aB5VzZ5MGKnnAh6xsiKAV") - .pipeline("https://github.com/pditommaso/nf-sleep") - .workDir("$TW_AGENT_WORK") - .paramsText("timeout: 60\n\n") - .dateCreated(OffsetDateTime.parse("2023-05-15T13:59:19Z")) - .lastUpdated(OffsetDateTime.parse("2023-05-15T08:23:29Z")) - .resume(false) - .pullLatest(false) - .stubRun(false) - .computeEnv( - parseJson("{\"id\": \"509cXW9NmIKYTe7KbjxyZn\"}", ComputeEnvComputeConfig.class) - .name("slurm_vallibierna") - ), - baseUserUrl(mock, USER_WORKSPACE_NAME) - ).toString()), out.stdOut - ); assertEquals(0, out.exitCode); + // Default version info should appear even without --version-id/--version-name + assertTrue(out.stdOut.contains("Version Name"), "Output should contain version name row"); + assertTrue(out.stdOut.contains("sleep_one_minute-1"), "Output should contain default version name"); + assertTrue(out.stdOut.contains("Version Is Default"), "Output should contain version default row"); + assertTrue(out.stdOut.contains("abc123def456"), "Output should contain version hash"); } @@ -1514,4 +1748,1000 @@ void testRemoveLabels(OutputType format, MockServerClient mock) { ExecOut out = exec(format,mock, "pipelines", "labels","-n","lab1","-o","delete", "l1,l2"); assertOutput(format,out, new ManageLabels("delete","pipeline","8858801873955",null)); } + + // --- Version ID / Version Name wiring tests --- + + @Test + void testViewWithVersionId(MockServerClient mock) throws JsonProcessingException { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\"") + .withQueryStringParameter("visibility", "all"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "deleted": false, + "lastUpdated": "2023-05-15T13:59:19Z" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856") + .withQueryStringParameter("attributes", "labels"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "deleted": false, + "lastUpdated": "2023-05-15T13:59:19Z", + "labels": [] + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock version resolution via versions list (findPipelineVersionByRef) + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "version": { + "id": "7TnlaOKANkiDIdDqOO2kCs", + "name": "v1.0", + "hash": "abc123hash", + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856/launch") + .withQueryStringParameter("versionId", "7TnlaOKANkiDIdDqOO2kCs"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "launch": { + "id": "aB5VzZ5MGKnnAh6xsiKAV", + "computeEnv": { + "id": "509cXW9NmIKYTe7KbjxyZn", + "name": "slurm_vallibierna", + "platform": "slurm-platform", + "config": { + "workDir": "$TW_AGENT_WORK", + "discriminator": "slurm-platform" + }, + "primary": true + }, + "pipeline": "https://github.com/pditommaso/nf-sleep", + "workDir": "$TW_AGENT_WORK", + "paramsText": "timeout: 60\\n\\n", + "resume": false, + "pullLatest": false, + "stubRun": false, + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z" + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "view", "-n", "sleep_one_minute", "--version-id", "7TnlaOKANkiDIdDqOO2kCs"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.contains("Version Name"), "Output should contain version name row"); + assertTrue(out.stdOut.contains("v1.0"), "Output should contain version name value"); + assertTrue(out.stdOut.contains("Version Is Default"), "Output should contain version default row"); + assertTrue(out.stdOut.contains("abc123hash"), "Output should contain version hash"); + } + + @Test + void testViewWithVersionName(MockServerClient mock) throws JsonProcessingException { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\"") + .withQueryStringParameter("visibility", "all"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "deleted": false, + "lastUpdated": "2023-05-15T13:59:19Z" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856") + .withQueryStringParameter("attributes", "labels"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "deleted": false, + "lastUpdated": "2023-05-15T13:59:19Z", + "labels": [] + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock version name resolution via versions list + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856/versions") + .withQueryStringParameter("search", "v1.0") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "userId": 1776, + "userName": "jordi10", + "version": { + "id": "7TnlaOKANkiDIdDqOO2kCs", + "name": "v1.0", + "hash": "def456hash", + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856/launch") + .withQueryStringParameter("versionId", "7TnlaOKANkiDIdDqOO2kCs"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "launch": { + "id": "aB5VzZ5MGKnnAh6xsiKAV", + "computeEnv": { + "id": "509cXW9NmIKYTe7KbjxyZn", + "name": "slurm_vallibierna", + "platform": "slurm-platform", + "config": { + "workDir": "$TW_AGENT_WORK", + "discriminator": "slurm-platform" + }, + "primary": true + }, + "pipeline": "https://github.com/pditommaso/nf-sleep", + "workDir": "$TW_AGENT_WORK", + "paramsText": "timeout: 60\\n\\n", + "resume": false, + "pullLatest": false, + "stubRun": false, + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z" + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/user-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "view", "-n", "sleep_one_minute", "--version-name", "v1.0"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.contains("Version Name"), "Output should contain version name row"); + assertTrue(out.stdOut.contains("v1.0"), "Output should contain version name value"); + assertTrue(out.stdOut.contains("def456hash"), "Output should contain version hash"); + } + + @Test + void testViewWithVersionNameNotFound(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\"") + .withQueryStringParameter("visibility", "all"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856") + .withQueryStringParameter("attributes", "labels"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "labels": [] + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/213164477645856/versions") + .withQueryStringParameter("search", "nonexistent") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [], + "totalSize": 0 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "view", "-n", "sleep_one_minute", "--version-name", "nonexistent"); + + assertEquals(errorMessage(out.app, new TowerException("Pipeline version 'nonexistent' not found")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testExportWithVersionId(MockServerClient mock) throws JsonProcessingException { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines").withQueryStringParameter("search", "\"sleep\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_sleep")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/183522618315672"), exactly(1) + ).respond( + response().withStatusCode(200).withContentType(MediaType.APPLICATION_JSON) + .withBody(""" + { + "pipeline": { + "pipelineId": 183522618315672, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + } + }""") + ); + + // Mock version resolution via versions list (findPipelineVersionByRef) + mock.when( + request().withMethod("GET").withPath("/pipelines/183522618315672/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 183522618315672, + "name": "sleep_one_minute", + "version": { + "id": "abc123", + "name": "v1.0", + "hash": "exporthash", + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/183522618315672/launch") + .withQueryStringParameter("versionId", "abc123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "export", "-n", "sleep", "--version-id", "abc123"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertTrue(out.stdOut.contains("\"version\""), "Exported JSON should contain version field"); + assertTrue(out.stdOut.contains("\"v1.0\""), "Exported JSON should contain version name"); + } + + @Test + void testExportWithVersionNameNotFound(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_sleep")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/183522618315672"), exactly(1) + ).respond( + response().withStatusCode(200).withContentType(MediaType.APPLICATION_JSON) + .withBody(""" + { + "pipeline": { + "pipelineId": 183522618315672, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + } + }""") + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/183522618315672/versions") + .withQueryStringParameter("search", "nonexistent") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [], + "totalSize": 0 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "export", "-n", "sleep", "--version-name", "nonexistent"); + + assertEquals(errorMessage(out.app, new TowerException("Pipeline version 'nonexistent' not found")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testUpdateWithVersionId(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // describePipeline returns the default version on pipe.getVersion() + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "ver123", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/ver123") + .withBody(json(""" + { + "description": "Sleep one minute and exit", + "name": "sleep_one_minute", + "launch": { + "computeEnvId": "vYOK4vn7spw7bHHWBDXZ2", + "pipeline": "https://github.com/pditommaso/nf-sleep", + "workDir": "s3://nextflow-ci/jordeu", + "paramsText": "timeout: 60\\n", + "pullLatest": false, + "stubRun": false + } + }""" + )), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-d", "Sleep one minute and exit", "--version-id", "ver123"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute").toString(), out.stdOut); + } + + @Test + void testUpdateWithVersionName(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock version name resolution via versions list + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("search", "v2.0") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "userId": 4, + "userName": "jordi", + "version": { + "id": "ver456", + "name": "v2.0", + "dateCreated": "2023-05-15T13:59:19Z", + "lastUpdated": "2023-05-15T13:59:19Z", + "isDefault": false + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "ver456"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/ver456") + .withBody(json(""" + { + "description": "Sleep one minute and exit", + "name": "sleep_one_minute", + "launch": { + "computeEnvId": "vYOK4vn7spw7bHHWBDXZ2", + "pipeline": "https://github.com/pditommaso/nf-sleep", + "workDir": "s3://nextflow-ci/jordeu", + "paramsText": "timeout: 60\\n", + "pullLatest": false, + "stubRun": false + } + }""" + )), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\"}}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-d", "Sleep one minute and exit", "--version-name", "v2.0"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute").toString(), out.stdOut); + } + + @Test + void testUpdateWithVersionNameNotFound(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("search", "nonexistent") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [], + "totalSize": 0 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-d", "desc", "--version-name", "nonexistent"); + + assertEquals(errorMessage(out.app, new TowerException("Pipeline version 'nonexistent' not found")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testUpdateDraftVersionCreated(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // describePipeline returns the default version — its name is used for auto-naming + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Update returns a new unnamed draft (different ID) + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "draft789", + "dateCreated": "2023-06-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": false + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock validatePipelineVersionName + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions/validate") + .withQueryStringParameter("name", "sleep_one_minute-2"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + // Mock managePipelineVersion to assign name and promote to default + mock.when( + request().withMethod("PUT").withPath("/pipelines/217997727159863/versions/draft789/manage"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "sleep_one_minute-2").toString(), out.stdOut); + } + + @Test + void testUpdateDraftVersionCreatedWithAllowDraft(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Update returns a new unnamed draft (different ID) + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "draft789", + "dateCreated": "2023-06-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": false + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // No validate or manage mocks — --allow-draft skips auto-naming + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch", "--allow-draft"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", null, "draft789").toString(), out.stdOut); + } + + @Test + void testUpdateVersionWithDraftCreated(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // describePipeline returns default version name "sleep_one_minute-3" for auto-naming + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "ver123", + "name": "sleep_one_minute-3", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "draft456", + "dateCreated": "2023-06-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": false + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock validatePipelineVersionName + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions/validate") + .withQueryStringParameter("name", "sleep_one_minute-4"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + // Mock managePipelineVersion + mock.when( + request().withMethod("PUT").withPath("/pipelines/217997727159863/versions/draft456/manage"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch", "--version-id", "ver123"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "sleep_one_minute-4").toString(), out.stdOut); + } + + @Test + void testUpdateNewVersionAlreadyNamed(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "ver123", + "name": "sleep_one_minute-3", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Server returns a new version that is already named and set as default (fixed API behavior) + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "newver789", + "name": "sleep_one_minute-4", + "dateCreated": "2023-06-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // No validate or manage calls should be made — server already named the version + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch", "--version-id", "ver123"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "sleep_one_minute-4").toString(), out.stdOut); + } + + @Test + void testUpdateNoDraftCreated(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "ver123", + "name": "v1.0", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "ver123", + "name": "v1.0", + "dateCreated": "2023-05-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-d", "Updated description", "--version-id", "ver123"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute").toString(), out.stdOut); + } } diff --git a/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java b/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java new file mode 100644 index 00000000..914c43ba --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java @@ -0,0 +1,154 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed 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 io.seqera.tower.cli.utils; + +import io.seqera.tower.ApiException; +import io.seqera.tower.api.PipelineVersionsApi; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VersionNameHelperTest { + + @Test + void testGenerateNextVersionName_noSuffix() { + assertEquals("pipeline-1", VersionNameHelper.generateNextVersionName("pipeline")); + } + + @Test + void testGenerateNextVersionName_withSuffix() { + assertEquals("pipeline-6", VersionNameHelper.generateNextVersionName("pipeline-5")); + } + + @Test + void testGenerateNextVersionName_dotVersion() { + assertEquals("v1.0-2", VersionNameHelper.generateNextVersionName("v1.0-1")); + } + + @Test + void testGenerateNextVersionName_multipleHyphens() { + assertEquals("my-pipeline-name-4", VersionNameHelper.generateNextVersionName("my-pipeline-name-3")); + } + + @Test + void testGenerateNextVersionName_trailingHyphenNoNumber() { + assertEquals("pipeline--1", VersionNameHelper.generateNextVersionName("pipeline-")); + } + + @Test + void testGenerateNextVersionName_onlyVersionNumber() { + assertEquals("-6", VersionNameHelper.generateNextVersionName("-5")); + } + + @Test + void testGenerateNextVersionName_null() { + assertNull(VersionNameHelper.generateNextVersionName(null)); + } + + @Test + void testGenerateNextVersionName_empty() { + assertEquals("", VersionNameHelper.generateNextVersionName("")); + } + + @Test + void testGenerateValidVersionName_firstCandidateValid() throws ApiException { + // API that accepts all names + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) { + // success — name is valid + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline", 1L, 1L, api); + + assertEquals("pipeline-1", result); + } + + @Test + void testGenerateValidVersionName_incrementsOnConflict() throws ApiException { + Set taken = Set.of("pipeline-1"); + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) throws ApiException { + if (taken.contains(name)) { + throw new ApiException(400, "Name taken"); + } + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline", 1L, 1L, api); + + assertEquals("pipeline-2", result); + } + + @Test + void testGenerateValidVersionName_withExistingSuffix() throws ApiException { + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) { + // success + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline-3", 1L, 1L, api); + + assertEquals("pipeline-4", result); + } + + @Test + void testGenerateValidVersionName_fallsBackToRandom() throws ApiException { + Set validated = new HashSet<>(); + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) throws ApiException { + validated.add(name); + throw new ApiException(400, "Name taken"); + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline", 1L, 1L, api); + + assertTrue(result.startsWith("pipeline-")); + assertEquals(4, result.substring("pipeline-".length()).length()); + assertEquals(10, validated.size()); + } + + @Test + void testGenerateValidVersionName_nullBaseName() throws ApiException { + PipelineVersionsApi api = new PipelineVersionsApi(); + + String result = VersionNameHelper.generateValidVersionName(null, 1L, 1L, api); + + assertTrue(result.startsWith("-")); + } + + @Test + void testGenerateValidVersionName_emptyBaseName() throws ApiException { + PipelineVersionsApi api = new PipelineVersionsApi(); + + String result = VersionNameHelper.generateValidVersionName("", 1L, 1L, api); + + assertTrue(result.startsWith("-")); + } +} diff --git a/src/test/resources/runcmd/pipeline_versions/pipeline_describe.json b/src/test/resources/runcmd/pipeline_versions/pipeline_describe.json new file mode 100644 index 00000000..822c4ef3 --- /dev/null +++ b/src/test/resources/runcmd/pipeline_versions/pipeline_describe.json @@ -0,0 +1,22 @@ +{ + "pipeline" : { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "description" : null, + "icon" : null, + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "userFirstName" : null, + "userLastName" : null, + "orgId" : null, + "orgName" : null, + "workspaceId" : null, + "workspaceName" : null, + "visibility" : null, + "deleted" : false, + "lastUpdated" : "2026-02-17T19:28:01Z", + "labels" : null, + "computeEnv" : null + } +} \ No newline at end of file diff --git a/src/test/resources/runcmd/pipeline_versions/pipelines_search.json b/src/test/resources/runcmd/pipeline_versions/pipelines_search.json new file mode 100644 index 00000000..d1625ee9 --- /dev/null +++ b/src/test/resources/runcmd/pipeline_versions/pipelines_search.json @@ -0,0 +1,21 @@ +{ + "pipelines" : [ + { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "description" : null, + "icon" : null, + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "userFirstName" : null, + "userLastName" : null, + "orgId" : null, + "orgName" : null, + "workspaceId" : null, + "workspaceName" : null, + "visibility" : null + } + ], + "totalSize" : 1 +} \ No newline at end of file diff --git a/src/test/resources/runcmd/pipeline_versions/versions_list.json b/src/test/resources/runcmd/pipeline_versions/versions_list.json new file mode 100644 index 00000000..8a9d2862 --- /dev/null +++ b/src/test/resources/runcmd/pipeline_versions/versions_list.json @@ -0,0 +1,65 @@ +{ + "versions" : [ + { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "version" : { + "id" : "7TnlaOKANkiDIdDqOO2kCs", + "creatorUserId" : 1, + "creatorUserName" : "jaime-munoz", + "creatorFirstName" : null, + "creatorLastName" : null, + "creatorAvatarUrl" : null, + "name" : "TestVersioningInUserWsp-1", + "dateCreated" : "2026-02-17T17:43:32Z", + "lastUpdated" : "2026-02-17T17:43:32Z", + "hash" : "JHY1OjIzYjNmYmVkN2NhZTU4Y2U0NDk1ZjA2MDY4YWRlOTE2MzJlMWFkMjlhY2RkNjY0NDM0MzFlMzY3NGEzNTBmNWMyOTIxMjhhMjNiMDMxMWU2ZjY2MmY4OTQ2OGVjOTRlMGNjMDVkNThkYTc2OGE2ZjVhNDlmY2JhZjY3YjNjYzY1", + "isDefault" : true + } + }, + { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "version" : { + "id" : "a48GJwfXIUUPIakcwFeue", + "creatorUserId" : 1, + "creatorUserName" : "jaime-munoz", + "creatorFirstName" : null, + "creatorLastName" : null, + "creatorAvatarUrl" : null, + "name" : "TestVersioningInUserWsp-2", + "dateCreated" : "2026-02-17T18:27:39Z", + "lastUpdated" : "2026-02-17T18:27:39Z", + "hash" : "JHY1OjU1MmEyZDEzZDI1MjA1MjJlNzc4MjdkM2M3ZmM2ZjdiMzhhYmMwNWEwZjNjYWM4MjlmYjI3MzU0MjNkNWI5YWQyNWVmYWFjNjQyNjUzNWQ5OGNlOTA5MWY1OTI3Yzg1OTk4MzAyYWM2ZTk1MzNhYzJmMjQzNGJiZTBkNjQ3MTg1", + "isDefault" : false + } + }, + { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "version" : { + "id" : "7KtabH1PaW1IBPYUdzVcXh", + "creatorUserId" : 1, + "creatorUserName" : "jaime-munoz", + "creatorFirstName" : null, + "creatorLastName" : null, + "creatorAvatarUrl" : null, + "name" : null, + "dateCreated" : "2026-02-17T18:28:01Z", + "lastUpdated" : "2026-02-17T18:28:01Z", + "hash" : "JHY1OjdlYmZmODY1MzUwMWRmNjJlMDc0YjIwNGY4MTExYTIwNzRmNTU2MzFjZjg4YTA1ODk1ZTAwMTM1NWUzMGQzZjZmOGQ4MGRhMTY5NTFmNTc3NWViMGYwYWYyZDM4NTBiYzZhZTcwODU3YTkyZWIyOGFiNjA2M2I4N2I4MWQ5MTlh", + "isDefault" : false + } + } + ], + "totalSize" : 3 +} \ No newline at end of file diff --git a/src/test/resources/runcmd/pipeline_versions/versions_published.json b/src/test/resources/runcmd/pipeline_versions/versions_published.json new file mode 100644 index 00000000..6bfe7441 --- /dev/null +++ b/src/test/resources/runcmd/pipeline_versions/versions_published.json @@ -0,0 +1,45 @@ +{ + "versions" : [ + { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "version" : { + "id" : "7TnlaOKANkiDIdDqOO2kCs", + "creatorUserId" : 1, + "creatorUserName" : "jaime-munoz", + "creatorFirstName" : null, + "creatorLastName" : null, + "creatorAvatarUrl" : null, + "name" : "TestVersioningInUserWsp-1", + "dateCreated" : "2026-02-17T17:43:32Z", + "lastUpdated" : "2026-02-17T17:43:32Z", + "hash" : "JHY1OjIzYjNmYmVkN2NhZTU4Y2U0NDk1ZjA2MDY4YWRlOTE2MzJlMWFkMjlhY2RkNjY0NDM0MzFlMzY3NGEzNTBmNWMyOTIxMjhhMjNiMDMxMWU2ZjY2MmY4OTQ2OGVjOTRlMGNjMDVkNThkYTc2OGE2ZjVhNDlmY2JhZjY3YjNjYzY1", + "isDefault" : true + } + }, + { + "pipelineId" : 188439584587120, + "name" : "TestVersioningInUserWsp", + "repository" : "https://github.com/nextflow-io/hello", + "userId" : 1, + "userName" : "jaime-munoz", + "version" : { + "id" : "a48GJwfXIUUPIakcwFeue", + "creatorUserId" : 1, + "creatorUserName" : "jaime-munoz", + "creatorFirstName" : null, + "creatorLastName" : null, + "creatorAvatarUrl" : null, + "name" : "TestVersioningInUserWsp-2", + "dateCreated" : "2026-02-17T18:27:39Z", + "lastUpdated" : "2026-02-17T18:27:39Z", + "hash" : "JHY1OjU1MmEyZDEzZDI1MjA1MjJlNzc4MjdkM2M3ZmM2ZjdiMzhhYmMwNWEwZjNjYWM4MjlmYjI3MzU0MjNkNWI5YWQyNWVmYWFjNjQyNjUzNWQ5OGNlOTA5MWY1OTI3Yzg1OTk4MzAyYWM2ZTk1MzNhYzJmMjQzNGJiZTBkNjQ3MTg1", + "isDefault" : false + } + } + ], + "totalSize" : 2 +} \ No newline at end of file