From fd648ccf00671e8b65ef1490a2dc0f323d950174 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Tue, 17 Feb 2026 20:52:57 +0100 Subject: [PATCH 01/24] feat: add pipeline version API client to the base abstract cmd --- .../java/io/seqera/tower/cli/commands/AbstractApiCmd.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 ee8d3db7..2fcd46b7 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.LaunchApi; import io.seqera.tower.api.OrgsApi; 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; @@ -114,6 +115,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private OrgsApi orgsApi; private PipelinesApi pipelinesApi; private PipelineSecretsApi pipelineSecretsApi; + private PipelineVersionsApi pipelineVersionsApi; private PlatformsApi platformsApi; private ServiceInfoApi serviceInfoApi; private StudiosApi studiosApi; @@ -229,6 +231,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; } From 86d01817cfdafecf6f6a0380140ff6e9073e64d2 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Tue, 17 Feb 2026 20:54:01 +0100 Subject: [PATCH 02/24] feat: utility classes update --- .../io/seqera/tower/cli/utils/FormatHelper.java | 13 +++++++++++++ .../java/io/seqera/tower/cli/utils/TableList.java | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) 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 9a103050..8b617a45 100644 --- a/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/FormatHelper.java @@ -351,4 +351,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 18e2902d..daf85dbe 100644 --- a/src/main/java/io/seqera/tower/cli/utils/TableList.java +++ b/src/main/java/io/seqera/tower/cli/utils/TableList.java @@ -76,7 +76,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])); } /** From 7403f266a9144d14826b7c81c0daa1d23247f2c8 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Tue, 17 Feb 2026 20:54:37 +0100 Subject: [PATCH 03/24] feat: pipeline versions list cmd --- conf/reflect-config.json | 19 ++++ .../tower/cli/commands/PipelinesCmd.java | 2 + .../commands/pipelines/versions/ListCmd.java | 89 +++++++++++++++++++ .../pipelines/versions/VersionsCmd.java | 15 ++++ .../ListPipelineVersionsCmdResponse.java | 70 +++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ListCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ListPipelineVersionsCmdResponse.java diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 83fdca23..86397f42 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1492,6 +1492,18 @@ "allDeclaredMethods":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.VersionsCmd", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.AbstractRunsCmd", "allDeclaredFields":true, @@ -2158,6 +2170,12 @@ "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.runs.RunCanceled", "allDeclaredFields":true, @@ -3983,6 +4001,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"] }] 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 d684935e..3a2c480f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java @@ -25,6 +25,7 @@ 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; @@ -40,6 +41,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/versions/ListCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ListCmd.java new file mode 100644 index 00000000..d5da109b --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ListCmd.java @@ -0,0 +1,89 @@ +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.exceptions.PipelineNotFoundException; +import io.seqera.tower.cli.exceptions.TowerException; +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 = "Show only pipeline versions with name that contain the given word") + 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", required = false) + 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); + + if (pipeline == null) throwPipelineNotFoundException(pipelineRefOptions, wspId); + + Integer max = PaginationOptions.getMax(paginationOptions); + Integer offset = PaginationOptions.getOffset(paginationOptions, max); + + // you can only filter by name versions with a name attached (published versions) + if (filter != null) { + isPublishedOption = true; + } + + ListPipelineVersionsResponse response = pipelineVersionsApi().listPipelineVersions( + pipeline.getPipelineId(), + wspId, + max, offset, + filter, + isPublishedOption + ); + + if (response.getVersions() == null) { + throw new TowerException("No versions available for the pipeline, check if Pipeline versioning feature is enabled for the workspace"); + } + + 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); + } + + private void throwPipelineNotFoundException(PipelineRefOptions pipelineRefOptions, Long wspId) throws ApiException, PipelineNotFoundException { + if (pipelineRefOptions.pipeline.pipelineId != null) { + throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineId, workspaceRef(wspId)); + } + throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineName, workspaceRef(wspId)); + } +} 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..26bd1b73 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionsCmd.java @@ -0,0 +1,15 @@ +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, + } +) +public class VersionsCmd extends AbstractRootCmd { +} 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..db1c4d5c --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ListPipelineVersionsCmdResponse.java @@ -0,0 +1,70 @@ +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, 5, "Name", "IsDefault", "Hash", "Creator", "Created At"); + + versions.stream() + .sorted(Comparator.comparing(PipelineVersionFullInfoDto::getDateCreated)) + .forEach(version -> table.addRow( + 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(); + } +} From ff8a78c41c46343e58aeb2635b433a1a37843d97 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Tue, 17 Feb 2026 21:16:48 +0100 Subject: [PATCH 04/24] fix: missing license header --- .../commands/pipelines/versions/ListCmd.java | 17 +++++++++++++++++ .../pipelines/versions/VersionsCmd.java | 17 +++++++++++++++++ .../ListPipelineVersionsCmdResponse.java | 17 +++++++++++++++++ 3 files changed, 51 insertions(+) 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 index d5da109b..b5e262cf 100644 --- 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 @@ -1,3 +1,20 @@ +/* + * Copyright 2021-2023, 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; 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 index 26bd1b73..7c02657b 100644 --- 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 @@ -1,3 +1,20 @@ +/* + * Copyright 2021-2023, 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; 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 index db1c4d5c..6e4cc5f4 100644 --- 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 @@ -1,3 +1,20 @@ +/* + * Copyright 2021-2023, 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; From ed4aef5f68ae78edfe3fc6ae88eb3b9c9bb515a0 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Tue, 17 Feb 2026 21:18:05 +0100 Subject: [PATCH 05/24] feat: unit test for pipeline versions cmd --- conf/resource-config.json | 4 +- .../pipelines/PipelineVersionsCmdTest.java | 313 ++++++++++++++++++ .../pipeline_versions/pipeline_describe.json | 22 ++ .../pipeline_versions/pipelines_search.json | 21 ++ .../pipeline_versions/versions_list.json | 65 ++++ .../pipeline_versions/versions_published.json | 45 +++ 6 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java create mode 100644 src/test/resources/runcmd/pipeline_versions/pipeline_describe.json create mode 100644 src/test/resources/runcmd/pipeline_versions/pipelines_search.json create mode 100644 src/test/resources/runcmd/pipeline_versions/versions_list.json create mode 100644 src/test/resources/runcmd/pipeline_versions/versions_published.json 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/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..55145877 --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -0,0 +1,313 @@ +/* + * Copyright 2021-2023, 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.exceptions.TowerException; +import io.seqera.tower.cli.responses.pipelines.versions.ListPipelineVersionsCmdResponse; +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; + +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 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) + ); + } + + @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") + .withQueryStringParameter("isPublished", "true"), + 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 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 testListVersionsFeatureDisabled(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody("{\"versions\":null,\"totalSize\":0}") + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "list", "-i", PIPELINE_ID.toString()); + + assertEquals(errorMessage(out.app, new TowerException("No versions available for the pipeline, check if Pipeline versioning feature is enabled for the workspace")), 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); + } +} 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 From 935d716f1b6a785d53a1683653322070032215e0 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 19:19:22 +0100 Subject: [PATCH 06/24] chore: move error handler into base class --- .../tower/cli/commands/pipelines/AbstractPipelinesCmd.java | 7 +++++++ .../tower/cli/commands/pipelines/versions/ListCmd.java | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java index 14679ae7..1c5c1fec 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java @@ -63,6 +63,13 @@ protected PipelineDbDto fetchPipeline(PipelineRefOptions pipelineRefOptions, Lon return pipelinesApi().describePipeline(pipelineId, List.of(attributes), wspId, null).getPipeline(); } + protected void throwPipelineNotFoundException(PipelineRefOptions pipelineRefOptions, Long wspId) throws ApiException, PipelineNotFoundException { + if (pipelineRefOptions.pipeline.pipelineId != null) { + throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineId, workspaceRef(wspId)); + } + throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineName, workspaceRef(wspId)); + } + private static String quotePipelineName(String pipelineName) { if (pipelineName == null) return null; 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 index b5e262cf..a0669bc8 100644 --- 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 @@ -96,11 +96,4 @@ protected Response exec() throws ApiException { return new ListPipelineVersionsCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), versions, PaginationInfo.from(offset, max), showFullHash); } - - private void throwPipelineNotFoundException(PipelineRefOptions pipelineRefOptions, Long wspId) throws ApiException, PipelineNotFoundException { - if (pipelineRefOptions.pipeline.pipelineId != null) { - throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineId, workspaceRef(wspId)); - } - throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineName, workspaceRef(wspId)); - } } From 649fb067e90be498a0927bfe2d1adb55abacf004 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 19:20:18 +0100 Subject: [PATCH 07/24] feat: pipeline versions view cmd --- .../commands/pipelines/versions/ViewCmd.java | 100 ++++++++++++++++++ .../ViewPipelineVersionCmdResponse.java | 63 +++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ViewPipelineVersionCmdResponse.java diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java new file mode 100644 index 00000000..b377ce9d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java @@ -0,0 +1,100 @@ +/* + * Copyright 2021-2023, 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.ViewPipelineVersionCmdResponse; +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.Objects; +import java.util.function.Predicate; + +@Command( + name = "view", + description = "View pipeline version details" +) +public class ViewCmd extends AbstractPipelinesCmd { + + @CommandLine.Mixin + PipelineRefOptions pipelineRefOptions; + + @CommandLine.Mixin + WorkspaceOptionalOptions workspaceOptions; + + @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; + } + + @Override + protected Response exec() throws ApiException { + + Long wspId = workspaceId(workspaceOptions.workspace); + PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); + + if (pipeline == null) { + throwPipelineNotFoundException(pipelineRefOptions, wspId); + } + + PipelineVersionFullInfoDto version = findVersionByRef(pipeline.getPipelineId(), wspId, versionRef); + + if (version == null) { + String ref = versionRef.versionId != null ? versionRef.versionId : versionRef.versionName; + throw new TowerException(String.format("Pipeline version '%s' not found", ref)); + } + + return new ViewPipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), version); + } + + private PipelineVersionFullInfoDto findVersionByRef(Long pipelineId, Long wspId, 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); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ViewPipelineVersionCmdResponse.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ViewPipelineVersionCmdResponse.java new file mode 100644 index 00000000..a10d695e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ViewPipelineVersionCmdResponse.java @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2023, 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; +import io.seqera.tower.cli.utils.FormatHelper; +import io.seqera.tower.cli.utils.TableList; +import io.seqera.tower.model.PipelineVersionFullInfoDto; + +import java.io.PrintWriter; + +public class ViewPipelineVersionCmdResponse extends Response { + + public final String workspaceRef; + public final Long pipelineId; + public final String pipelineName; + public final PipelineVersionFullInfoDto version; + + public ViewPipelineVersionCmdResponse(String workspaceRef, Long pipelineId, String pipelineName, PipelineVersionFullInfoDto version) { + this.workspaceRef = workspaceRef; + this.pipelineId = pipelineId; + this.pipelineName = pipelineName; + this.version = version; + } + + @Override + public void toString(PrintWriter out) { + + if (workspaceRef != null) { + out.println(ansi(String.format("%n @|bold Pipeline version of '%s' in workspace %s :|@%n", pipelineName, workspaceRef))); + } else { + out.println(ansi(String.format("%n @|bold Pipeline version of '%s' in user workspace:|@%n", pipelineName))); + } + + TableList table = new TableList(out, 2); + table.setPrefix(" "); + table.addRow("ID", version.getId()); + table.addRow("Name", version.getName() != null ? version.getName() : "(draft)"); + table.addRow("Is Default", version.getIsDefault() != null && version.getIsDefault() ? "yes" : "no"); + table.addRow("Hash", version.getHash()); + table.addRow("Creator", version.getCreatorUserName()); + table.addRow("Created At", FormatHelper.formatTime(version.getDateCreated())); + table.addRow("Last Updated", FormatHelper.formatTime(version.getLastUpdated())); + table.print(); + + out.println(); + } +} From 2557c3be265d4b25f71eab48e917ff883a7665d7 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 19:22:28 +0100 Subject: [PATCH 08/24] feat: pipeline version update cmd --- .../pipelines/versions/UpdateCmd.java | 92 +++++++++++++++++++ .../UpdatePipelineVersionCmdResponse.java | 43 +++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/versions/UpdatePipelineVersionCmdResponse.java diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java new file mode 100644 index 00000000..bd4a3945 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java @@ -0,0 +1,92 @@ +/* + * Copyright 2021-2023, 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.UpdatePipelineVersionCmdResponse; +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 = "update", + description = "Update a pipeline version name or default flag" +) +public class UpdateCmd extends AbstractPipelinesCmd { + + @CommandLine.Mixin + PipelineRefOptions pipelineRefOptions; + + @CommandLine.Mixin + WorkspaceOptionalOptions workspaceOptions; + + @CommandLine.Option(names = {"--version-id"}, description = "Pipeline version identifier", required = true) + public String versionId; + + @CommandLine.ArgGroup(exclusive = false, multiplicity = "1", + heading = "%nUpdate options (at least one required):%n") + public UpdateOptions updateOptions; + + public static class UpdateOptions { + @CommandLine.Option(names = {"--version-name"}, description = "New name for the pipeline version") + public String name; + + @CommandLine.Option(names = {"--set-default"}, description = "Set (true) or unset (false) this version as the default", arity = "1") + public Boolean isDefault; + } + + @Override + protected Response exec() throws ApiException { + + Long wspId = workspaceId(workspaceOptions.workspace); + PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); + + if (pipeline == null) { + throwPipelineNotFoundException(pipelineRefOptions, wspId); + } + + PipelineVersionManageRequest request = new PipelineVersionManageRequest() + .name(updateOptions.name) + .isDefault(updateOptions.isDefault); + + try { + pipelineVersionsApi().managePipelineVersion( + pipeline.getPipelineId(), + versionId, + request, + wspId + ); + } catch (ApiException e) { + if (e.getCode() == 400) { + throw new TowerException(String.format("Invalid version name '%s': %s", updateOptions.name, ResponseHelper.decodeMessage(e))); + } + throw new TowerException( + String.format("Unable to update pipeline version '%s': %s", versionId, ResponseHelper.decodeMessage(e)) + ); + } + + return new UpdatePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), versionId); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/UpdatePipelineVersionCmdResponse.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/UpdatePipelineVersionCmdResponse.java new file mode 100644 index 00000000..42884f0e --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/UpdatePipelineVersionCmdResponse.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2023, 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 UpdatePipelineVersionCmdResponse extends Response { + + public final String workspaceRef; + public final Long pipelineId; + public final String pipelineName; + public final String versionId; + + public UpdatePipelineVersionCmdResponse(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)); + } +} From 67c0a77c94d55d03d73263d6b458925795559760 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 19:30:54 +0100 Subject: [PATCH 09/24] feat: include commands in the root versioning class --- conf/reflect-config.json | 12 ++++++++++++ .../cli/commands/pipelines/versions/VersionsCmd.java | 2 ++ 2 files changed, 14 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 86397f42..c00ae9b9 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -2176,6 +2176,18 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.pipelines.versions.PipelineVersionUpdated", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.pipelines.versions.ViewPipelineVersionCmdResponse", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.runs.RunCanceled", "allDeclaredFields":true, 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 index 7c02657b..e59535f5 100644 --- 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 @@ -26,6 +26,8 @@ description = "Manage pipeline versions", subcommands = { ListCmd.class, + ViewCmd.class, + UpdateCmd.class, } ) public class VersionsCmd extends AbstractRootCmd { From ee94e586a2bac88c9dac8164029b973ab8dcc66a Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 19:31:36 +0100 Subject: [PATCH 10/24] feat: unit tests for versions view and update cmds --- .../pipelines/PipelineVersionsCmdTest.java | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java index 55145877..98e7d2e6 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -22,6 +22,8 @@ import io.seqera.tower.cli.exceptions.PipelineNotFoundException; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.pipelines.versions.ListPipelineVersionsCmdResponse; +import io.seqera.tower.cli.responses.pipelines.versions.UpdatePipelineVersionCmdResponse; +import io.seqera.tower.cli.responses.pipelines.versions.ViewPipelineVersionCmdResponse; import io.seqera.tower.cli.utils.PaginationInfo; import io.seqera.tower.model.PipelineVersionFullInfoDto; import org.junit.jupiter.api.Test; @@ -39,6 +41,7 @@ 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 { @@ -49,6 +52,8 @@ class PipelineVersionsCmdTest extends BaseCmdTest { 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() @@ -139,6 +144,20 @@ private void mockVersionsList(MockServerClient mock) { ); } + 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 --- + // GET-only: no request body to verify. Path and query parameter matching (search, isPublished, max, offset) + // in the mocks below is sufficient to assert the CLI sends the correct parameters to the server. + @ParameterizedTest @EnumSource(OutputType.class) void testListVersionsByName(OutputType format, MockServerClient mock) { @@ -310,4 +329,191 @@ void testListVersionsWithFullHash(MockServerClient mock) { allVersions(), PaginationInfo.from((Integer) null, (Integer) null), true ).toString()), out.stdOut); } + + // --- View command tests --- + // GET-only: no request body to verify. Path and query parameter matching (search, isPublished) + // in the mocks below is sufficient to assert the CLI sends the correct parameters to the server. + + @ParameterizedTest + @EnumSource(OutputType.class) + void testViewVersionById(OutputType format, MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockVersionsList(mock); + + ExecOut out = exec(format, mock, "pipelines", "versions", "view", "-i", PIPELINE_ID.toString(), "--version-id", VERSION_ID_V1); + + assertOutput(format, out, new ViewPipelineVersionCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, allVersions().get(0) + )); + } + + @Test + void testViewVersionByName(MockServerClient mock) { + + mock.reset(); + mockPipelineSearchByName(mock); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") + .withQueryStringParameter("search", "TestVersioningInUserWsp-2") + .withQueryStringParameter("isPublished", "true"), + exactly(1) + ).respond( + response().withStatusCode(200) + .withBody(loadResource("pipeline_versions/versions_published")) + .withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "pipelines", "versions", "view", "-n", PIPELINE_NAME, "--version-name", "TestVersioningInUserWsp-2"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ViewPipelineVersionCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, publishedVersions().get(1) + ).toString()), out.stdOut); + } + + @Test + void testViewVersionNotFound(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockVersionsList(mock); + + ExecOut out = exec(mock, "pipelines", "versions", "view", "-i", PIPELINE_ID.toString(), "--version-id", "nonexistent"); + + assertEquals(errorMessage(out.app, new TowerException("Pipeline version 'nonexistent' not found")), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } + + @Test + void testViewDraftVersionById(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockVersionsList(mock); + + ExecOut out = exec(mock, "pipelines", "versions", "view", "-i", PIPELINE_ID.toString(), "--version-id", "7KtabH1PaW1IBPYUdzVcXh"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ViewPipelineVersionCmdResponse( + null, PIPELINE_ID, PIPELINE_NAME, allVersions().get(2) + ).toString()), out.stdOut); + } + + // --- Update command tests --- + // PUT requests: body verification via json() matcher ensures the CLI serializes the correct + // PipelineVersionManageRequest fields (name, isDefault) for each combination of CLI flags. + + @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", "update", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--version-name", "new-version-name"); + + assertOutput(format, out, new UpdatePipelineVersionCmdResponse(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", "update", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--set-default", "true"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new UpdatePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + } + + @Test + void testUpdateVersionUnsetDefault(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + mockManageVersion(mock, "{\"isDefault\":false}"); + + ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--set-default", "false"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new UpdatePipelineVersionCmdResponse(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", "update", "-n", PIPELINE_NAME, + "--version-id", VERSION_ID_V1, "--version-name", "renamed-version"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(new UpdatePipelineVersionCmdResponse(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", "update", "-i", PIPELINE_ID.toString(), + "--version-id", VERSION_ID_V1, "--version-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", "update", "-n", "nonexistent", + "--version-id", VERSION_ID_V1, "--version-name", "new-name"); + + assertEquals(errorMessage(out.app, new PipelineNotFoundException("\"nonexistent\"", USER_WORKSPACE_NAME)), out.stdErr); + assertEquals("", out.stdOut); + assertEquals(1, out.exitCode); + } } From 4de3e484b26da67feb3bce4b09280964e94d664c Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 20:16:20 +0100 Subject: [PATCH 11/24] fix: remove unsetting default flag use case, update version by name --- conf/reflect-config.json | 25 +++++++++++ .../pipelines/AbstractPipelinesCmd.java | 28 +++++++++++++ .../pipelines/versions/UpdateCmd.java | 29 +++++++++---- .../pipelines/versions/VersionRefOptions.java | 35 ++++++++++++++++ .../commands/pipelines/versions/ViewCmd.java | 42 ++----------------- .../pipelines/PipelineVersionsCmdTest.java | 39 +++++++++++------ 6 files changed, 140 insertions(+), 58 deletions(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionRefOptions.java diff --git a/conf/reflect-config.json b/conf/reflect-config.json index c00ae9b9..5dc4f5e0 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1498,12 +1498,36 @@ "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.VersionsCmd", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.ViewCmd", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"io.seqera.tower.cli.commands.pipelines.versions.ViewCmd$VersionRef", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.runs.AbstractRunsCmd", "allDeclaredFields":true, @@ -4338,6 +4362,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/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java index 1c5c1fec..31ee2ccc 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java @@ -19,14 +19,20 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.exceptions.MultiplePipelinesFoundException; import io.seqera.tower.cli.exceptions.PipelineNotFoundException; +import io.seqera.tower.cli.exceptions.TowerException; +import io.seqera.tower.model.ListPipelineVersionsResponse; import io.seqera.tower.model.ListPipelinesResponse; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineQueryAttribute; +import io.seqera.tower.model.PipelineVersionFullInfoDto; import picocli.CommandLine.Command; import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; @Command public abstract class AbstractPipelinesCmd extends AbstractApiCmd { @@ -70,6 +76,28 @@ protected void throwPipelineNotFoundException(PipelineRefOptions pipelineRefOpti throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineName, workspaceRef(wspId)); } + protected PipelineVersionFullInfoDto findVersionByRef(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); + } + private static String quotePipelineName(String pipelineName) { if (pipelineName == null) return null; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java index bd4a3945..70f2733e 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java @@ -26,6 +26,7 @@ import io.seqera.tower.cli.responses.pipelines.versions.UpdatePipelineVersionCmdResponse; import io.seqera.tower.cli.utils.ResponseHelper; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.PipelineVersionManageRequest; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -42,18 +43,18 @@ public class UpdateCmd extends AbstractPipelinesCmd { @CommandLine.Mixin WorkspaceOptionalOptions workspaceOptions; - @CommandLine.Option(names = {"--version-id"}, description = "Pipeline version identifier", required = true) - public String versionId; + @CommandLine.Mixin + VersionRefOptions versionRefOptions; @CommandLine.ArgGroup(exclusive = false, multiplicity = "1", heading = "%nUpdate options (at least one required):%n") public UpdateOptions updateOptions; public static class UpdateOptions { - @CommandLine.Option(names = {"--version-name"}, description = "New name for the pipeline version") + @CommandLine.Option(names = {"--new-name"}, description = "New name for the pipeline version") public String name; - @CommandLine.Option(names = {"--set-default"}, description = "Set (true) or unset (false) this version as the default", arity = "1") + @CommandLine.Option(names = {"--set-default"}, description = "Set this version as the default") public Boolean isDefault; } @@ -67,6 +68,8 @@ protected Response exec() throws ApiException { throwPipelineNotFoundException(pipelineRefOptions, wspId); } + String resolvedVersionId = resolveVersionId(pipeline.getPipelineId(), wspId); + PipelineVersionManageRequest request = new PipelineVersionManageRequest() .name(updateOptions.name) .isDefault(updateOptions.isDefault); @@ -74,7 +77,7 @@ protected Response exec() throws ApiException { try { pipelineVersionsApi().managePipelineVersion( pipeline.getPipelineId(), - versionId, + resolvedVersionId, request, wspId ); @@ -83,10 +86,22 @@ protected Response exec() throws ApiException { throw new TowerException(String.format("Invalid version name '%s': %s", updateOptions.name, ResponseHelper.decodeMessage(e))); } throw new TowerException( - String.format("Unable to update pipeline version '%s': %s", versionId, ResponseHelper.decodeMessage(e)) + String.format("Unable to update pipeline version '%s': %s", resolvedVersionId, ResponseHelper.decodeMessage(e)) ); } - return new UpdatePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), versionId); + return new UpdatePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), resolvedVersionId); + } + + private String resolveVersionId(Long pipelineId, Long wspId) throws ApiException { + if (versionRefOptions.versionRef.versionId != null) { + return versionRefOptions.versionRef.versionId; + } + + PipelineVersionFullInfoDto version = findVersionByRef(pipelineId, wspId, versionRefOptions.versionRef); + if (version == null) { + throw new TowerException(String.format("Pipeline version '%s' not found", versionRefOptions.versionRef.versionName)); + } + return version.getId(); } } 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..702ef26d --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/VersionRefOptions.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2023, 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/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java index b377ce9d..80893b97 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java @@ -24,15 +24,11 @@ import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.versions.ViewPipelineVersionCmdResponse; -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.Objects; -import java.util.function.Predicate; - @Command( name = "view", description = "View pipeline version details" @@ -45,16 +41,8 @@ public class ViewCmd extends AbstractPipelinesCmd { @CommandLine.Mixin WorkspaceOptionalOptions workspaceOptions; - @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; - } + @CommandLine.Mixin + VersionRefOptions versionRefOptions; @Override protected Response exec() throws ApiException { @@ -66,35 +54,13 @@ protected Response exec() throws ApiException { throwPipelineNotFoundException(pipelineRefOptions, wspId); } - PipelineVersionFullInfoDto version = findVersionByRef(pipeline.getPipelineId(), wspId, versionRef); + PipelineVersionFullInfoDto version = findVersionByRef(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); if (version == null) { - String ref = versionRef.versionId != null ? versionRef.versionId : versionRef.versionName; + String ref = versionRefOptions.versionRef.versionId != null ? versionRefOptions.versionRef.versionId : versionRefOptions.versionRef.versionName; throw new TowerException(String.format("Pipeline version '%s' not found", ref)); } return new ViewPipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), version); } - - private PipelineVersionFullInfoDto findVersionByRef(Long pipelineId, Long wspId, 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); - } } diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java index 98e7d2e6..d20ed7ce 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -419,7 +419,7 @@ void testUpdateVersionName(OutputType format, MockServerClient mock) { mockManageVersion(mock, "{\"name\":\"new-version-name\"}"); ExecOut out = exec(format, mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), - "--version-id", VERSION_ID_V1, "--version-name", "new-version-name"); + "--version-id", VERSION_ID_V1, "--new-name", "new-version-name"); assertOutput(format, out, new UpdatePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1)); } @@ -432,7 +432,7 @@ void testUpdateVersionSetDefault(MockServerClient mock) { mockManageVersion(mock, "{\"isDefault\":true}"); ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), - "--version-id", VERSION_ID_V1, "--set-default", "true"); + "--version-id", VERSION_ID_V1, "--set-default"); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); @@ -440,14 +440,15 @@ void testUpdateVersionSetDefault(MockServerClient mock) { } @Test - void testUpdateVersionUnsetDefault(MockServerClient mock) { + void testUpdateVersionByPipelineName(MockServerClient mock) { mock.reset(); + mockPipelineSearchByName(mock); mockPipelineDescribe(mock); - mockManageVersion(mock, "{\"isDefault\":false}"); + mockManageVersion(mock, "{\"name\":\"renamed-version\"}"); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), - "--version-id", VERSION_ID_V1, "--set-default", "false"); + ExecOut out = exec(mock, "pipelines", "versions", "update", "-n", PIPELINE_NAME, + "--version-id", VERSION_ID_V1, "--new-name", "renamed-version"); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); @@ -455,15 +456,27 @@ void testUpdateVersionUnsetDefault(MockServerClient mock) { } @Test - void testUpdateVersionByPipelineName(MockServerClient mock) { + void testUpdateVersionByVersionName(MockServerClient mock) { mock.reset(); - mockPipelineSearchByName(mock); mockPipelineDescribe(mock); - mockManageVersion(mock, "{\"name\":\"renamed-version\"}"); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-n", PIPELINE_NAME, - "--version-id", VERSION_ID_V1, "--version-name", "renamed-version"); + // 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", "update", "-i", PIPELINE_ID.toString(), + "--version-name", "TestVersioningInUserWsp-1", "--new-name", "renamed"); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); @@ -487,7 +500,7 @@ void testUpdateVersionInvalidName(MockServerClient mock) { ); ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), - "--version-id", VERSION_ID_V1, "--version-name", "!invalid!"); + "--version-id", VERSION_ID_V1, "--new-name", "!invalid!"); assertEquals(1, out.exitCode); assertEquals("", out.stdOut); @@ -510,7 +523,7 @@ void testUpdateVersionPipelineNotFound(MockServerClient mock) { ); ExecOut out = exec(mock, "pipelines", "versions", "update", "-n", "nonexistent", - "--version-id", VERSION_ID_V1, "--version-name", "new-name"); + "--version-id", VERSION_ID_V1, "--new-name", "new-name"); assertEquals(errorMessage(out.app, new PipelineNotFoundException("\"nonexistent\"", USER_WORKSPACE_NAME)), out.stdErr); assertEquals("", out.stdOut); From 627b72a0b1a95c695ffd6f564a35a09fe4af58eb Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 18 Feb 2026 23:16:34 +0100 Subject: [PATCH 12/24] chore: move version resolution to base api cmd --- .../tower/cli/commands/AbstractApiCmd.java | 37 +++++++++++++++++++ .../pipelines/AbstractPipelinesCmd.java | 28 -------------- .../pipelines/versions/UpdateCmd.java | 15 +------- .../commands/pipelines/versions/ViewCmd.java | 2 +- 4 files changed, 39 insertions(+), 43 deletions(-) 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 2fcd46b7..245b48f3 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -44,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; @@ -60,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; @@ -83,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; @@ -580,6 +584,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/pipelines/AbstractPipelinesCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java index 31ee2ccc..1c5c1fec 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/AbstractPipelinesCmd.java @@ -19,20 +19,14 @@ import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.AbstractApiCmd; -import io.seqera.tower.cli.commands.pipelines.versions.VersionRefOptions; import io.seqera.tower.cli.exceptions.MultiplePipelinesFoundException; import io.seqera.tower.cli.exceptions.PipelineNotFoundException; -import io.seqera.tower.cli.exceptions.TowerException; -import io.seqera.tower.model.ListPipelineVersionsResponse; import io.seqera.tower.model.ListPipelinesResponse; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineQueryAttribute; -import io.seqera.tower.model.PipelineVersionFullInfoDto; import picocli.CommandLine.Command; import java.util.List; -import java.util.Objects; -import java.util.function.Predicate; @Command public abstract class AbstractPipelinesCmd extends AbstractApiCmd { @@ -76,28 +70,6 @@ protected void throwPipelineNotFoundException(PipelineRefOptions pipelineRefOpti throw new PipelineNotFoundException(pipelineRefOptions.pipeline.pipelineName, workspaceRef(wspId)); } - protected PipelineVersionFullInfoDto findVersionByRef(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); - } - private static String quotePipelineName(String pipelineName) { if (pipelineName == null) return null; diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java index 70f2733e..29d9884c 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java @@ -26,7 +26,6 @@ import io.seqera.tower.cli.responses.pipelines.versions.UpdatePipelineVersionCmdResponse; import io.seqera.tower.cli.utils.ResponseHelper; import io.seqera.tower.model.PipelineDbDto; -import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.PipelineVersionManageRequest; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -68,7 +67,7 @@ protected Response exec() throws ApiException { throwPipelineNotFoundException(pipelineRefOptions, wspId); } - String resolvedVersionId = resolveVersionId(pipeline.getPipelineId(), wspId); + String resolvedVersionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); PipelineVersionManageRequest request = new PipelineVersionManageRequest() .name(updateOptions.name) @@ -92,16 +91,4 @@ protected Response exec() throws ApiException { return new UpdatePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), resolvedVersionId); } - - private String resolveVersionId(Long pipelineId, Long wspId) throws ApiException { - if (versionRefOptions.versionRef.versionId != null) { - return versionRefOptions.versionRef.versionId; - } - - PipelineVersionFullInfoDto version = findVersionByRef(pipelineId, wspId, versionRefOptions.versionRef); - if (version == null) { - throw new TowerException(String.format("Pipeline version '%s' not found", versionRefOptions.versionRef.versionName)); - } - return version.getId(); - } } diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java index 80893b97..0263fecb 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java @@ -54,7 +54,7 @@ protected Response exec() throws ApiException { throwPipelineNotFoundException(pipelineRefOptions, wspId); } - PipelineVersionFullInfoDto version = findVersionByRef(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); + PipelineVersionFullInfoDto version = findPipelineVersionByRef(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); if (version == null) { String ref = versionRefOptions.versionRef.versionId != null ? versionRefOptions.versionRef.versionId : versionRefOptions.versionRef.versionName; From 4080fc61ee82026c06e69374d69fdde55648cfda Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:03:36 +0100 Subject: [PATCH 13/24] feat: versioning support for 'pipelines add' cmd, refactor error handling --- .../tower/cli/commands/pipelines/AddCmd.java | 76 +++++++++++-------- 1 file changed, 46 insertions(+), 30 deletions(-) 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 47724bb0..222f513a 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 @@ -20,12 +20,15 @@ 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.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; @@ -56,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; @@ -75,7 +81,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) { @@ -88,35 +94,45 @@ 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) - .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) + .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()); } From f8c6d46b3b56d84d6ca6b30dd760c09299a76ef8 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:04:03 +0100 Subject: [PATCH 14/24] feat: versioning support for 'pipelines view' cmd, refactor error handling --- .../io/seqera/tower/cli/commands/pipelines/ViewCmd.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 da8a4a27..04ffd2b2 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 @@ -19,6 +19,7 @@ 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.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesView; import io.seqera.tower.model.LaunchDbDto; @@ -39,12 +40,17 @@ 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(); + String versionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRef); + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); return new PipelinesView(workspaceRef(wspId), pipeline, launch, baseWorkspaceUrl(wspId)); } } From aebd93932b568b8a6a561d43747b9eeabb08e2de Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:04:28 +0100 Subject: [PATCH 15/24] feat: versioning support for 'pipelines export' cmd --- .../io/seqera/tower/cli/commands/pipelines/ExportCmd.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 c53d6095..3cf9459b 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 @@ -21,6 +21,7 @@ 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; @@ -43,6 +44,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; @@ -51,7 +56,8 @@ 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(); + String versionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRef); + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); WorkflowLaunchRequest workflowLaunchRequest = ModelHelper.createLaunchRequest(launch); From a1534f05facd35287b730e3f27764ac003860b05 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:05:58 +0100 Subject: [PATCH 16/24] feat: versioning support for 'pipelines launch' cmd --- src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 1f22d2c6..a3f1fea4 100644 --- a/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/LaunchCmd.java @@ -21,6 +21,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; @@ -83,6 +84,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; @@ -178,8 +183,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) { From 6acd7b932e67f9557a6f114488f4fbde226acdc6 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:08:07 +0100 Subject: [PATCH 17/24] feat: versioning support for 'pipelines update' cmd, detect when draft versions are created after updates --- .../cli/commands/pipelines/UpdateCmd.java | 45 +++++++++++++++++-- .../responses/pipelines/PipelinesUpdated.java | 14 +++++- 2 files changed, 55 insertions(+), 4 deletions(-) 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 d463b9c9..3bf0266b 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 @@ -19,13 +19,16 @@ 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.model.LaunchDbDto; import io.seqera.tower.model.PipelineDbDto; +import io.seqera.tower.model.PipelineVersionFullInfoDto; 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; @@ -62,6 +65,10 @@ public class UpdateCmd extends AbstractPipelinesCmd { @Option(names = {"--pipeline"}, description = "Nextflow pipeline URL") public String pipeline; + // 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 { @@ -86,8 +93,9 @@ protected Response exec() throws ApiException, IOException { } } + String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipe); - LaunchDbDto launch = pipelinesApi().describePipelineLaunch(id, wspId, sourceWorkspaceId, null).getLaunch(); + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(id, wspId, sourceWorkspaceId, versionId).getLaunch(); // Retrieve the provided computeEnv or use the primary if not provided String ceId = null; if (opts.computeEnv != null) { @@ -124,8 +132,39 @@ protected Response exec() throws ApiException, IOException { .workspaceSecrets(coalesce(removeEmptyValues(opts.workspaceSecrets), launch.getWorkspaceSecrets())) ); - pipelinesApi().updatePipeline(pipe.getPipelineId(), updateReq, wspId); + // NOTE: The server automatically creates a new draft version when versionable fields change. + // Non-versionable fields are updated in place. + // The (Web) frontend detects versionable changes client-side and opens a modal to let the + // user publish the draft with a name. For the CLI, we must manage the draft version afterward. - return new PipelinesUpdated(workspaceRef(wspId), pipe.getName()); + UpdatePipelineResponse response; + if (versionId != null) { + response = pipelineVersionsApi().updatePipelineVersion(pipe.getPipelineId(), versionId, updateReq, wspId); + } else { + response = pipelinesApi().updatePipeline(pipe.getPipelineId(), updateReq, wspId); + } + + String draftVersionId = detectNewDraftVersionId(response, versionId); + return new PipelinesUpdated(workspaceRef(wspId), pipe.getName(), draftVersionId); + } + + /** + * Detects if the server auto-created a new draft version because versionable fields changed. + * A draft version has no name and its ID differs from the version we targeted. + */ + private String detectNewDraftVersionId(UpdatePipelineResponse response, String requestedVersionId) { + if (response == null || response.getPipeline() == null) { + return null; + } + PipelineVersionFullInfoDto version = response.getPipeline().getVersion(); + if (version == null) { + return null; + } + boolean isDraft = version.getName() == null; + boolean isDifferentVersion = !version.getId().equals(requestedVersionId); + if (isDraft && isDifferentVersion) { + return version.getId(); + } + return null; } } 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 21a0867a..eb6b9b3a 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 @@ -18,19 +18,31 @@ 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 draftVersionId; public PipelinesUpdated(String workspaceRef, String pipelineName) { + this(workspaceRef, pipelineName, null); + } + + public PipelinesUpdated(String workspaceRef, String pipelineName, @Nullable String draftVersionId) { this.workspaceRef = workspaceRef; this.pipelineName = pipelineName; + 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 (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")); } } From 840a7ce6ce0a850504afb983500dd5bbb7f6d6ca Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:10:15 +0100 Subject: [PATCH 18/24] feat: updated unit tests --- .../io/seqera/tower/cli/LaunchCmdTest.java | 160 ++++ .../tower/cli/pipelines/PipelinesCmdTest.java | 843 ++++++++++++++++++ 2 files changed, 1003 insertions(+) diff --git a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java index 8e07a84f..59730942 100644 --- a/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/LaunchCmdTest.java @@ -23,6 +23,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; @@ -589,4 +590,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/PipelinesCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java index 37bdd623..58da89d6 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -410,6 +410,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 { @@ -479,6 +547,127 @@ void testAddWithCommitId(MockServerClient mock) throws IOException { assertEquals(0, out.exitCode); } + @Test + 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\":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(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\":\"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", "sleep_one_minute", "--version-name", "v1.0", "https://github.com/pditommaso/nf-sleep"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesAdded(USER_WORKSPACE_NAME, "sleep_one_minute").toString(), out.stdOut); + assertEquals(0, out.exitCode); + } + + @Test + 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) + ); + + 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(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(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", "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 testAddWithComputeEnv(MockServerClient mock) { @@ -1514,4 +1703,658 @@ 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.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); + } + + @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", + "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); + } + + @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.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); + } + + @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) + ); + + 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 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/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) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("PUT").withPath("/pipelines/217997727159863"), 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) + ); + + 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", "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) + ); + + 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) + ); + + 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", "draft456").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/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); + } } From d7c9857f1f5a0f4a9aaef0ebc39758603499c19f Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 19:10:26 +0100 Subject: [PATCH 19/24] feat: updated reflection files --- conf/reflect-config.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 5dc4f5e0..1a9b5954 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1510,6 +1510,18 @@ "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, @@ -2188,6 +2200,12 @@ "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, From 845818e040e5cb7ac4111a01a45b792a1392bf74 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 20:40:19 +0100 Subject: [PATCH 20/24] feat: include versioning data in 'pipelines export' cmd ouput --- .../cli/commands/pipelines/ExportCmd.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 3cf9459b..1a01411b 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 @@ -26,9 +26,12 @@ 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; @@ -56,7 +59,20 @@ protected Response exec() throws ApiException { Long wspId = workspaceId(workspace.workspace); PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipeline); - String versionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRef); + + 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); @@ -65,6 +81,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 = ""; From 510a6a8de669020f4d948da326c4c5a96e673578 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 20:41:14 +0100 Subject: [PATCH 21/24] feat: include versioning data in 'pipelines view' cmd output --- .../tower/cli/commands/pipelines/ViewCmd.java | 20 +++++++++++++++++-- .../responses/pipelines/PipelinesView.java | 15 ++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) 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 04ffd2b2..e2ffbd27 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 @@ -20,11 +20,13 @@ 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; @@ -49,8 +51,22 @@ protected Response exec() throws ApiException { Long wspId = workspaceId(workspace.workspace); PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId, PipelineQueryAttribute.labels); Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipeline); - String versionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRef); + + 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) { + // 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)); + } + } + LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); - return new PipelinesView(workspaceRef(wspId), pipeline, launch, baseWorkspaceUrl(wspId)); + return new PipelinesView(workspaceRef(wspId), pipeline, launch, version, baseWorkspaceUrl(wspId)); } } 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 8146736f..f0c3a860 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 @@ -21,11 +21,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.seqera.tower.JSON; import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.utils.FormatHelper; import io.seqera.tower.cli.utils.ModelHelper; 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 jakarta.annotation.Nullable; import java.io.PrintWriter; @@ -37,14 +40,21 @@ public class PipelinesView extends Response { public final String workspaceRef; public final PipelineDbDto info; public final LaunchDbDto launch; + @Nullable + public final PipelineVersionFullInfoDto version; @JsonIgnore private final String baseWorkspaceUrl; public PipelinesView(String workspaceRef, PipelineDbDto info, LaunchDbDto launch, String baseWorkspaceUrl) { + this(workspaceRef, info, launch, null, baseWorkspaceUrl); + } + + public PipelinesView(String workspaceRef, PipelineDbDto info, LaunchDbDto launch, @Nullable PipelineVersionFullInfoDto version, String baseWorkspaceUrl) { this.workspaceRef = workspaceRef; this.info = info; this.launch = launch; + this.version = version; this.baseWorkspaceUrl = baseWorkspaceUrl; } @@ -67,6 +77,11 @@ 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())); + if (version != null) { + 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)^", " "))); From 838be7462ef3901b5ac49b0744929b97422c91b1 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 20:42:27 +0100 Subject: [PATCH 22/24] feat: update tests with versioning data output --- .../tower/cli/pipelines/PipelinesCmdTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 58da89d6..1549c0d2 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -62,6 +62,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; @@ -1746,6 +1747,28 @@ void testViewWithVersionId(MockServerClient mock) throws JsonProcessingException }""").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) @@ -1786,6 +1809,10 @@ void testViewWithVersionId(MockServerClient mock) throws JsonProcessingException 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 @@ -1845,6 +1872,7 @@ void testViewWithVersionName(MockServerClient mock) throws JsonProcessingExcepti "version": { "id": "7TnlaOKANkiDIdDqOO2kCs", "name": "v1.0", + "hash": "def456hash", "dateCreated": "2023-05-15T13:59:19Z", "lastUpdated": "2023-05-15T13:59:19Z", "isDefault": true @@ -1894,6 +1922,9 @@ void testViewWithVersionName(MockServerClient mock) throws JsonProcessingExcepti 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 @@ -1976,6 +2007,28 @@ void testExportWithVersionId(MockServerClient mock) throws JsonProcessingExcepti }""") ); + // 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) @@ -1987,6 +2040,8 @@ void testExportWithVersionId(MockServerClient mock) throws JsonProcessingExcepti 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 From 8cdca257e921beb38a05947d77bad93d9e93653f Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 20:44:06 +0100 Subject: [PATCH 23/24] refactor: move pipeline labels subcommands to separate package --- conf/reflect-config.json | 2 +- src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java | 2 +- .../java/io/seqera/tower/cli/commands/pipelines/AddCmd.java | 1 + .../tower/cli/commands/pipelines/{ => labels}/LabelsCmd.java | 4 +++- .../pipelines/{ => labels}/PipelinesLabelsManager.java | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) rename src/main/java/io/seqera/tower/cli/commands/pipelines/{ => labels}/LabelsCmd.java (88%) rename src/main/java/io/seqera/tower/cli/commands/pipelines/{ => labels}/PipelinesLabelsManager.java (97%) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 1a9b5954..3a393fbb 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1451,7 +1451,7 @@ "methods":[{"name":"","parameterTypes":[] }] }, { - "name":"io.seqera.tower.cli.commands.pipelines.LabelsCmd", + "name":"io.seqera.tower.cli.commands.pipelines.labels.LabelsCmd", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] 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 3a2c480f..b555095b 100644 --- a/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/PipelinesCmd.java @@ -21,7 +21,7 @@ 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; 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 222f513a..86fcada3 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 @@ -20,6 +20,7 @@ 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; 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 beb2d9c5..891a47c3 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 @@ -15,10 +15,12 @@ * */ -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 af90ca92..6fd02326 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 @@ -15,7 +15,7 @@ * */ -package io.seqera.tower.cli.commands.pipelines; +package io.seqera.tower.cli.commands.pipelines.labels; import java.util.List; From 63358de7062baa4d5566edb0b183e0e4bc117363 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 19 Feb 2026 21:38:25 +0100 Subject: [PATCH 24/24] fix: reflection files --- conf/reflect-config.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 3a393fbb..0f2bf296 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1450,12 +1450,6 @@ "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.LaunchOptions", "allDeclaredFields":true, @@ -1492,6 +1486,12 @@ "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, @@ -2219,7 +2219,7 @@ "queryAllDeclaredConstructors":true }, { - "name":"io.seqera.tower.cli.responses.pipelines.versions.PipelineVersionUpdated", + "name":"io.seqera.tower.cli.responses.pipelines.versions.UpdatePipelineVersionCmdResponse", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true