From fd648ccf00671e8b65ef1490a2dc0f323d950174 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Tue, 17 Feb 2026 20:52:57 +0100 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 From 51049317c82b637fe3d67684b9b597f07e6cfedb Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Mon, 23 Feb 2026 20:01:51 +0100 Subject: [PATCH 25/43] fix: ensure retro-compatibility, auto name and promote if the changes trigger a draft version --- .../cli/commands/pipelines/UpdateCmd.java | 254 ++++++++-- .../responses/pipelines/PipelinesUpdated.java | 15 +- .../tower/cli/utils/VersionNameHelper.java | 111 +++++ .../tower/cli/pipelines/PipelinesCmdTest.java | 471 ++++++++++++++---- .../cli/utils/VersionNameHelperTest.java | 154 ++++++ 5 files changed, 859 insertions(+), 146 deletions(-) create mode 100644 src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java create mode 100644 src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java 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 0e83c7fc..7f87b14b 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 @@ -21,12 +21,16 @@ 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.exceptions.TowerException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesUpdated; import io.seqera.tower.cli.utils.FilesHelper; +import io.seqera.tower.cli.utils.VersionNameHelper; import io.seqera.tower.model.LaunchDbDto; +import io.seqera.tower.model.ListPipelineVersionsResponse; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineVersionFullInfoDto; +import io.seqera.tower.model.PipelineVersionManageRequest; import io.seqera.tower.model.UpdatePipelineRequest; import io.seqera.tower.model.UpdatePipelineResponse; import io.seqera.tower.model.WorkflowLaunchRequest; @@ -68,6 +72,9 @@ public class UpdateCmd extends AbstractPipelinesCmd { @Option(names = {"--pipeline"}, description = "Nextflow pipeline URL") public String pipeline; + @Option(names = {"--allow-draft"}, description = "If versionable fields change, keep the new version as an unnamed draft instead of auto-naming and promoting it to default.") + public boolean allowDraft; + // Explicit "0..1" for clarity — contrasts with the required "1" in VersionRefOptions. @Mixin won't work here as it would lose mutual exclusivity. @CommandLine.ArgGroup(multiplicity = "0..1") public VersionRefOptions.VersionRef versionRef; @@ -76,41 +83,132 @@ public class UpdateCmd extends AbstractPipelinesCmd { protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); - Long orgId = wspId != null ? orgId(wspId) : null; - PipelineDbDto pipe; - Long id; + // Resolve the pipeline by --id or --name so we have its metadata (name, description, etc.) + PipelineDbDto pipe = fetchPipeline(wspId); + + // If the user wants to rename the pipeline (--new-name), validate early before making any + // changes. The server checks uniqueness within the workspace/org scope. + validateNewName(newName, wspId); + + // Determine which version to update. If the user passed --version-id or --version-name, we + // target that specific version; otherwise we target the pipeline's current default version. + // We also eagerly fetch the default version's name here — it's needed later to derive the + // auto-generated name if a new draft version is created by the server. + VersionTarget target = resolveVersionTarget(pipe, wspId); + + // Fetch the existing launch configuration for the target version so we can merge the user's + // CLI overrides on top of the current values (coalesce pattern: user value wins, else keep existing). + LaunchDbDto launch = fetchLaunch(pipe, wspId, target.versionId); + + // Resolve the compute environment: use --compute-env if provided, otherwise keep whatever + // the launch already references. This needs a separate API call when the user specifies a + // CE by name (to look up its ID). + String ceId = resolveComputeEnvId(wspId, launch); + + // Build the update payload merging CLI flags with existing launch values. + UpdatePipelineRequest updateReq = buildUpdateRequest(pipe, launch, ceId); + + // Send the update to the versioned endpoint (POST /pipelines/{id}/versions/{vid}). + // If versionable fields (revision, pipeline URL, etc.) changed, the server may create a + // new draft version instead of updating the existing one in-place. + UpdatePipelineResponse response = pipelineVersionsApi() + .updatePipelineVersion(pipe.getPipelineId(), target.versionId, updateReq, wspId); + + // Detect whether a new version was created and handle it: + // - Server already named it (fixed API) → just report it. + // - Unnamed draft + --allow-draft → report draft ID, let user manage it manually. + // - Unnamed draft (default behavior) → auto-generate a name, assign it, and promote to default + // (mirrors the frontend naming algorithm from version-name-resolver.ts). + return handleVersioningResult(response, target, pipe, wspId); + } + + // --- Pipeline resolution --- + + private PipelineDbDto fetchPipeline(Long wspId) throws ApiException { if (pipelineRefOptions.pipeline.pipelineId != null) { - id = pipelineRefOptions.pipeline.pipelineId; - pipe = pipelinesApi().describePipeline(id, Collections.emptyList(), wspId, null).getPipeline(); - } else { - pipe = pipelineByName(wspId, pipelineRefOptions.pipeline.pipelineName); - id = pipe.getPipelineId(); + Long id = pipelineRefOptions.pipeline.pipelineId; + return pipelinesApi().describePipeline(id, Collections.emptyList(), wspId, null).getPipeline(); + } + return pipelineByName(wspId, pipelineRefOptions.pipeline.pipelineName); + } + + private void validateNewName(String newName, Long wspId) throws ApiException { + if (newName == null) return; + Long orgId = wspId != null ? orgId(wspId) : null; + try { + pipelinesApi().validatePipelineName(wspId, orgId, newName); + } catch (ApiException ex) { + throw new InvalidResponseException(String.format("Pipeline name '%s' is not valid", newName)); } + } - if (newName != null) { - try { - pipelinesApi().validatePipelineName(wspId, orgId, newName); - } catch (ApiException ex) { - throw new InvalidResponseException(String.format("Pipeline name '%s' is not valid", newName)); - } + // --- Version resolution --- + + /** + * Carries the resolved version ID to update and the current default version's name. + * The default version name is needed later by autoNameAndPromote to derive the next + * incremental name (e.g. "pipeline-3" → "pipeline-4"). + */ + private static class VersionTarget { + final String versionId; + final String defaultVersionName; + + VersionTarget(String versionId, String defaultVersionName) { + this.versionId = versionId; + this.defaultVersionName = defaultVersionName; + } + } + + private VersionTarget resolveVersionTarget(PipelineDbDto pipe, Long wspId) throws ApiException { + // Always fetch the default version eagerly — we need its name for auto-naming if a draft + // is created, regardless of whether we're updating the default or a user-specified version. + // Falls back to the pipeline name if no default version exists yet. + PipelineVersionFullInfoDto defaultVersion = fetchDefaultVersion(pipe.getPipelineId(), wspId); + String defaultVersionName = defaultVersion != null ? defaultVersion.getName() : pipe.getName(); + + if (versionRef != null) { + // User explicitly targeted a version via --version-id or --version-name + String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); + return new VersionTarget(versionId, defaultVersionName); } - String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); - Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipe); - LaunchDbDto launch = pipelinesApi().describePipelineLaunch(id, wspId, sourceWorkspaceId, versionId).getLaunch(); - // Retrieve the provided computeEnv or use the primary if not provided - String ceId = null; + // No explicit version — target the default. Every pipeline should have one, but guard + // against edge cases (e.g. pipeline just created, no versions published yet). + if (defaultVersion == null) { + throw new TowerException(String.format("No default version found for pipeline '%s'", pipe.getName())); + } + return new VersionTarget(defaultVersion.getId(), defaultVersionName); + } + + // --- Launch and request building --- + + private LaunchDbDto fetchLaunch(PipelineDbDto pipe, Long wspId, String versionId) throws ApiException { + // sourceWorkspaceId handles shared pipelines — the launch config lives in the source + // workspace, not necessarily the workspace where the user is operating. + Long sourceWspId = sourceWorkspaceId(wspId, pipe); + return pipelinesApi().describePipelineLaunch(pipe.getPipelineId(), wspId, sourceWspId, versionId).getLaunch(); + } + + private String resolveComputeEnvId(Long wspId, LaunchDbDto launch) throws ApiException { + // User explicitly picked a compute environment — resolve its ID by name or ID ref. if (opts.computeEnv != null) { - ceId = computeEnvByRef(wspId, opts.computeEnv).getId(); - } else { - final var ce = launch.getComputeEnv(); - if (ce != null) { - ceId = ce.getId(); - } + return computeEnvByRef(wspId, opts.computeEnv).getId(); } + // Keep the existing CE from the launch; may be null for shared workspace pipelines + // that don't have a CE pinned. + var ce = launch.getComputeEnv(); + return ce != null ? ce.getId() : null; + } - UpdatePipelineRequest updateReq = new UpdatePipelineRequest() + /** + * Merges CLI-provided values with the existing launch configuration. + * Each field uses coalesce(userValue, existingValue): the user's flag wins if provided, + * otherwise we preserve the current server-side value. This lets users update individual + * fields without having to re-specify everything. + */ + private UpdatePipelineRequest buildUpdateRequest(PipelineDbDto pipe, LaunchDbDto launch, String ceId) throws IOException { + return new UpdatePipelineRequest() .name(coalesce(newName, pipe.getName())) .description(coalesce(description, pipe.getDescription())) .launch(new WorkflowLaunchRequest() @@ -121,8 +219,6 @@ protected Response exec() throws ApiException, IOException { .workDir(coalesce(opts.workDir, launch.getWorkDir())) .configProfiles(coalesce(opts.profile, launch.getConfigProfiles())) .paramsText(coalesce(FilesHelper.readString(opts.paramsFile), launch.getParamsText())) - - // Advanced options .configText(coalesce(FilesHelper.readString(opts.config), launch.getConfigText())) .preRunScript(coalesce(FilesHelper.readString(opts.preRunScript), launch.getPreRunScript())) .postRunScript(coalesce(FilesHelper.readString(opts.postRunScript), launch.getPostRunScript())) @@ -135,40 +231,90 @@ protected Response exec() throws ApiException, IOException { .userSecrets(coalesce(removeEmptyValues(opts.userSecrets), launch.getUserSecrets())) .workspaceSecrets(coalesce(removeEmptyValues(opts.workspaceSecrets), launch.getWorkspaceSecrets())) ); + } + + // --- Post-update version handling --- + // + // When versionable fields (revision, pipeline URL, etc.) change, the server creates a new + // version instead of modifying the existing one in-place. + // The new version is an unnamed draft (name=null, isDefault=false). + // We must auto-generate a name, assign it, and promote it to default so it appears in the + // Launchpad — unless the user passed --allow-draft to keep it as a draft. + // + // If no versionable fields changed, the server updates the version in-place and returns the + // same version ID — detectNewVersion returns null and we skip all of this. - // 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. + private Response handleVersioningResult( + UpdatePipelineResponse response, VersionTarget target, PipelineDbDto pipe, Long wspId + ) throws ApiException { + PipelineVersionFullInfoDto newVersion = detectNewVersion(response, target.versionId); - UpdatePipelineResponse response; - if (versionId != null) { - response = pipelineVersionsApi().updatePipelineVersion(pipe.getPipelineId(), versionId, updateReq, wspId); - } else { - response = pipelinesApi().updatePipeline(pipe.getPipelineId(), updateReq, wspId); + String newVersionName = null; + String draftVersionId = null; + + if (newVersion != null) { + if (newVersion.getName() != null) { + // Fixed API path: server already named the version and promoted it to default. + newVersionName = newVersion.getName(); + } else if (allowDraft) { + // User explicitly opted to keep the draft as-is for manual management via + // 'tw pipelines versions'. + draftVersionId = newVersion.getId(); + } else { + // Current API path: unnamed draft. Auto-name it following the frontend convention + // (e.g. "pipeline-3" → "pipeline-4") and promote it to default so it's immediately + // available in the Launchpad. + newVersionName = autoNameAndPromote(newVersion, target, pipe, wspId); + } } - String draftVersionId = detectNewDraftVersionId(response, versionId); - return new PipelinesUpdated(workspaceRef(wspId), pipe.getName(), draftVersionId); + return new PipelinesUpdated(workspaceRef(wspId), pipe.getName(), newVersionName, 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. + * Names an unnamed draft version and promotes it to default. This mirrors the frontend's + * behavior in version-name-resolver.ts: derive an incremental name from the current default + * version name, validate it against the server (to avoid collisions), and assign it. */ - private String detectNewDraftVersionId(UpdatePipelineResponse response, String requestedVersionId) { - if (response == null || response.getPipeline() == null) { - return null; - } + private String autoNameAndPromote( + PipelineVersionFullInfoDto newVersion, VersionTarget target, PipelineDbDto pipe, Long wspId + ) throws ApiException { + String versionName = VersionNameHelper.generateValidVersionName( + target.defaultVersionName, pipe.getPipelineId(), wspId, pipelineVersionsApi() + ); + + pipelineVersionsApi().managePipelineVersion( + pipe.getPipelineId(), + newVersion.getId(), + new PipelineVersionManageRequest().name(versionName).isDefault(true), + wspId + ); + + return versionName; + } + + /** + * Detects whether the server created a new version as a side effect of the update. + * If the response contains a version with a different ID than the one we sent the update to, + * that's a newly created version (either named or draft). Same ID means in-place update. + */ + private PipelineVersionFullInfoDto detectNewVersion(UpdatePipelineResponse response, String requestedVersionId) { + if (response == null || response.getPipeline() == null) return null; PipelineVersionFullInfoDto version = response.getPipeline().getVersion(); - if (version == null) { - return null; - } - boolean isDraft = version.getName() == null; - boolean isDifferentVersion = !version.getId().equals(requestedVersionId); - if (isDraft && isDifferentVersion) { - return version.getId(); - } - return null; + if (version == null) return null; + return version.getId().equals(requestedVersionId) ? null : version; + } + + private PipelineVersionFullInfoDto fetchDefaultVersion(Long pipelineId, Long wspId) throws ApiException { + // Query published versions and find the one marked as default. We use the published filter + // because draft versions should not be considered as the "current default" for naming. + ListPipelineVersionsResponse versionsResponse = pipelineVersionsApi() + .listPipelineVersions(pipelineId, wspId, null, null, null, true); + if (versionsResponse.getVersions() == null) return null; + return versionsResponse.getVersions().stream() + .map(PipelineDbDto::getVersion) + .filter(v -> v != null && Boolean.TRUE.equals(v.getIsDefault())) + .findFirst() + .orElse(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 eb6b9b3a..61f302e3 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 @@ -25,22 +25,31 @@ public class PipelinesUpdated extends Response { public final String workspaceRef; public final String pipelineName; @Nullable + public final String newVersionName; + @Nullable public final String draftVersionId; public PipelinesUpdated(String workspaceRef, String pipelineName) { - this(workspaceRef, pipelineName, null); + this(workspaceRef, pipelineName, null, null); + } + + public PipelinesUpdated(String workspaceRef, String pipelineName, @Nullable String newVersionName) { + this(workspaceRef, pipelineName, newVersionName, null); } - public PipelinesUpdated(String workspaceRef, String pipelineName, @Nullable String draftVersionId) { + public PipelinesUpdated(String workspaceRef, String pipelineName, @Nullable String newVersionName, @Nullable String draftVersionId) { this.workspaceRef = workspaceRef; this.pipelineName = pipelineName; + this.newVersionName = newVersionName; this.draftVersionId = draftVersionId; } @Override public String toString() { String msg = String.format("%n @|yellow Pipeline '%s' updated at %s workspace|@", pipelineName, workspaceRef); - if (draftVersionId != null) { + if (newVersionName != null) { + msg += String.format("%n @|yellow New version '%s' created and set as default, available in the Launchpad.|@", newVersionName); + } else if (draftVersionId != null) { msg += String.format("%n @|yellow New draft version '%s' created. Use 'tw pipelines versions' to manage it.|@", draftVersionId); } return ansi(msg + String.format("%n")); diff --git a/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java new file mode 100644 index 00000000..5a370058 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java @@ -0,0 +1,111 @@ +/* + * 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.utils; + +import io.seqera.tower.ApiException; +import io.seqera.tower.api.PipelineVersionsApi; + +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Port of the frontend version naming algorithm. + * + * Generates incremental version names by stripping a trailing -{number} suffix, + * incrementing it (or starting at 1), and validating with the server. + */ +public class VersionNameHelper { + + private static final int MAX_RETRIES = 10; + private static final long RETRY_DELAY_MS = 150; + private static final Pattern VERSION_SUFFIX = Pattern.compile("-(\\d+)$"); + + private VersionNameHelper() { + } + + /** + * Generates the next version name from a base name. + * Strips trailing -{number}, increments (or starts at 1). + * E.g., "pipeline-3" → "pipeline-4", "rnaseq" → "rnaseq-1" + */ + public static String generateNextVersionName(String baseName) { + if (baseName == null || baseName.isEmpty()) { + return baseName; + } + + Matcher matcher = VERSION_SUFFIX.matcher(baseName); + if (matcher.find()) { + int currentVersion = Integer.parseInt(matcher.group(1)); + String prefix = baseName.substring(0, matcher.start()); + return prefix + "-" + (currentVersion + 1); + } + + return baseName + "-1"; + } + + /** + * Generates a valid version name using the naming algorithm with server-side validation. + * Tries up to 20 incremental names, then falls back to a random 4-char suffix. + */ + public static String generateValidVersionName( + String baseName, + Long pipelineId, + Long wspId, + PipelineVersionsApi api + ) throws ApiException { + if (baseName == null || baseName.isEmpty()) { + return generateRandomFallback(baseName != null ? baseName : ""); + } + + Matcher matcher = VERSION_SUFFIX.matcher(baseName); + int startIndex; + String prefix; + + if (matcher.find()) { + startIndex = Integer.parseInt(matcher.group(1)) + 1; + prefix = baseName.substring(0, matcher.start()); + } else { + startIndex = 1; + prefix = baseName; + } + + for (int i = startIndex; i < startIndex + MAX_RETRIES; i++) { + String candidate = prefix + "-" + i; + try { + api.validatePipelineVersionName(pipelineId, candidate, wspId); + return candidate; + } catch (ApiException e) { + // Validation failed (name already taken), try next + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + + return generateRandomFallback(prefix); + } + + private static String generateRandomFallback(String prefix) { + String random = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + return prefix + "-" + random; + } +} diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java index fc0ca20c..d7a94f3d 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -82,16 +82,26 @@ void testUpdate(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") .withBody(json("{\"description\":\"Sleep one minute and exit\",\"name\":\"sleep_one_minute\",\"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pipelineSchemaId\":56789,\"pullLatest\":false,\"stubRun\":false}}")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleep_one_minute","description":"Sleep one minute and exit","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-d", "Sleep one minute and exit"); @@ -112,7 +122,16 @@ void testUpdateComputeEnv(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); @@ -130,10 +149,11 @@ void testUpdateComputeEnv(MockServerClient mock) { ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") .withBody(json("{\"name\":\"sleep_one_minute\",\"launch\":{\"computeEnvId\":\"isnEDBLvHDAIteOEF44ow\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "-c", "demo"); @@ -154,24 +174,33 @@ void testUpdatePipelineName(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "sleepOneMinute") ).respond( - response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(204) ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") - .withBody(json("{\"name\": \"sleepOneMinute\", \"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), - exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) ).respond( - response().withStatusCode(200) - .withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleepOneMinute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "sleepOneMinute") + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver") ).respond( - response().withStatusCode(204) + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") + .withBody(json("{\"name\": \"sleepOneMinute\", \"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), + exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleepOneMinute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--new-name", "sleepOneMinute"); @@ -189,67 +218,32 @@ void testUpdateWithCommitId(MockServerClient mock) { mock.when( request().withMethod("GET").withPath("/pipelines").withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) ).respond( - response().withStatusCode(200).withBody("{" + - "\"pipelines\":[{" + - "\"pipelineId\":217997727159863," + - "\"name\":\"sleep_one_minute\"," + - "\"description\":null," + - "\"icon\":null," + - "\"repository\":\"https://github.com/pditommaso/nf-sleep\"," + - "\"userId\":4," + - "\"userName\":\"jordi\"," + - "\"userFirstName\":null," + - "\"userLastName\":null," + - "\"orgId\":null," + - "\"orgName\":null," + - "\"workspaceId\":null," + - "\"workspaceName\":null," + - "\"visibility\":null" + - "}]," + - "\"totalSize\":1" + - "}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipelines":[{"pipelineId":217997727159863,"name":"sleep_one_minute","repository":"https://github.com/pditommaso/nf-sleep"}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") - .withBody(json("{" + - "\"name\":\"sleep_one_minute\"," + - "\"launch\":{" + - "\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\"," + - "\"pipeline\":\"https://github.com/pditommaso/nf-sleep\"," + - "\"commitId\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," + - "\"workDir\":\"s3://nextflow-ci/jordeu\"," + - "\"paramsText\":\"timeout: 60\\n\"," + - "\"pullLatest\":false," + - "\"stubRun\":false" + - "}" + - "}")), exactly(1) + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver") + .withBody(json(""" + {"name":"sleep_one_minute","launch":{"computeEnvId":"vYOK4vn7spw7bHHWBDXZ2","pipeline":"https://github.com/pditommaso/nf-sleep","commitId":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","workDir":"s3://nextflow-ci/jordeu","paramsText":"timeout: 60\\n","pullLatest":false,"stubRun":false}}""")), exactly(1) ).respond( - response().withStatusCode(200).withBody("{" + - "\"pipeline\":{" + - "\"pipelineId\":217997727159863," + - "\"name\":\"sleep_one_minute\"," + - "\"description\":null," + - "\"icon\":null," + - "\"repository\":\"https://github.com/pditommaso/nf-sleep\"," + - "\"commitId\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," + - "\"userId\":4," + - "\"userName\":\"jordi\"," + - "\"userFirstName\":null," + - "\"userLastName\":null," + - "\"orgId\":null," + - "\"orgName\":null," + - "\"workspaceId\":null," + - "\"workspaceName\":null," + - "\"visibility\":null" + - "}" + - "}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + {"pipeline":{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}}""").withContentType(MediaType.APPLICATION_JSON) ); ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--commit-id", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); @@ -270,21 +264,6 @@ void testUpdatePipelineInvalidName(MockServerClient mock) { response().withStatusCode(200).withBody("{\"pipelines\":[{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON) ); - mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch") - ).respond( - response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) - ); - - mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863") - .withBody(json("{\"name\": \"sleepOneMinute\", \"launch\":{\"computeEnvId\":\"vYOK4vn7spw7bHHWBDXZ2\",\"pipeline\":\"https://github.com/pditommaso/nf-sleep\",\"workDir\":\"s3://nextflow-ci/jordeu\",\"paramsText\":\"timeout: 60\\n\",\"pullLatest\":false,\"stubRun\":false}}")), - exactly(1) - ).respond( - response().withStatusCode(200) - .withBody("{\"pipeline\":{\"pipelineId\":217997727159863,\"name\":\"sleepOneMinute\",\"description\":\"Sleep one minute and exit\",\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}}").withContentType(MediaType.APPLICATION_JSON) - ); - mock.when( request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "#sleep") ).respond( @@ -330,7 +309,19 @@ void testUpdateInSharedWorkspaceWithoutCE(MockServerClient mock) throws IOExcept ); mock.when( - request().withMethod("GET").withPath("/pipelines/68359275903286/launch").withQueryStringParameter("workspaceId","59563405657242") + request().withMethod("GET").withPath("/pipelines/68359275903286/versions") + .withQueryStringParameter("workspaceId", "59563405657242") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{\"versions\":[{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"version\":{\"id\":\"default-ver\",\"name\":\"hello-pipeline-1\",\"isDefault\":true}}],\"totalSize\":1}") + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/68359275903286/launch") + .withQueryStringParameter("workspaceId","59563405657242") + .withQueryStringParameter("versionId", "default-ver") ).respond( response().withStatusCode(200) .withContentType(MediaType.APPLICATION_JSON) @@ -338,14 +329,15 @@ void testUpdateInSharedWorkspaceWithoutCE(MockServerClient mock) throws IOExcept ); mock.when( - request().withMethod("PUT").withPath("/pipelines/68359275903286").withQueryStringParameter("workspaceId","59563405657242") + request().withMethod("POST").withPath("/pipelines/68359275903286/versions/default-ver") + .withQueryStringParameter("workspaceId","59563405657242") .withContentType(MediaType.APPLICATION_JSON) .withBody("{\"name\":\"hello-pipeline\",\"launch\":{\"pipeline\":\"https://github.com/nextflow-io/hello\",\"revision\":\"master\",\"preRunScript\":\"yyy\",\"pullLatest\":false,\"stubRun\":false}}"), exactly(1) ).respond( response().withStatusCode(200) .withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/nextflow-io/hello\",\"userId\":312,\"userName\":\"andrea-tortorella\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":44406019030987,\"orgName\":\"test-cli-org\",\"workspaceId\":59563405657242,\"workspaceName\":\"SharedWS\",\"visibility\":\"SHARED\",\"deleted\":false,\"lastUpdated\":\"2024-11-12T13:34:06.403300434Z\",\"optimizationId\":null,\"optimizationTargets\":null,\"optimizationStatus\":null,\"labels\":null,\"computeEnv\":null}}") + .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"version\":{\"id\":\"default-ver\",\"name\":\"hello-pipeline-1\",\"isDefault\":true}}}") ); ExecOut out = exec(mock,"pipelines", "update", "--id", "68359275903286", "--workspace", "59563405657242", "--pre-run", tempFile("yyy","pre-run","txt")); @@ -2110,6 +2102,26 @@ void testUpdateWithVersionId(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Resolve default version (always fetched now) + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "ver123", + "name": "sleep_one_minute-1", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( request().withMethod("GET").withPath("/pipelines/217997727159863/launch") .withQueryStringParameter("versionId", "ver123"), exactly(1) @@ -2163,6 +2175,26 @@ void testUpdateWithVersionName(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Resolve default version (always fetched now) + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + // Mock version name resolution via versions list mock.when( request().withMethod("GET").withPath("/pipelines/217997727159863/versions") @@ -2242,6 +2274,26 @@ void testUpdateWithVersionNameNotFound(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Resolve default version (always fetched now) + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( request().withMethod("GET").withPath("/pipelines/217997727159863/versions") .withQueryStringParameter("search", "nonexistent") @@ -2281,14 +2333,36 @@ void testUpdateDraftVersionCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Resolve default version ID + provide default version name for auto-naming mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/launch"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) ); + // Update returns a new unnamed draft (different ID) mock.when( - request().withMethod("PUT").withPath("/pipelines/217997727159863"), exactly(1) + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { @@ -2305,10 +2379,99 @@ void testUpdateDraftVersionCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Mock validatePipelineVersionName + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions/validate") + .withQueryStringParameter("name", "sleep_one_minute-2"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + // Mock managePipelineVersion to assign name and promote to default + mock.when( + request().withMethod("PUT").withPath("/pipelines/217997727159863/versions/draft789/manage"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch"); assertEquals("", out.stdErr); - assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "draft789").toString(), out.stdOut); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "sleep_one_minute-2").toString(), out.stdOut); + } + + @Test + void testUpdateDraftVersionCreatedWithAllowDraft(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Resolve default version ID + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/launch") + .withQueryStringParameter("versionId", "default-ver"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("pipelines_update")).withContentType(MediaType.APPLICATION_JSON) + ); + + // Update returns a new unnamed draft (different ID) + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/default-ver"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "draft789", + "dateCreated": "2023-06-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": false + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // No validate or manage mocks — --allow-draft skips auto-naming + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch", "--allow-draft"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", null, "draft789").toString(), out.stdOut); } @Test @@ -2356,10 +2519,120 @@ void testUpdateVersionWithDraftCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Mock listPipelineVersions to return a default version + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "ver123", + "name": "sleep_one_minute-3", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Mock validatePipelineVersionName + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions/validate") + .withQueryStringParameter("name", "sleep_one_minute-4"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + // Mock managePipelineVersion + mock.when( + request().withMethod("PUT").withPath("/pipelines/217997727159863/versions/draft456/manage"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch", "--version-id", "ver123"); + + assertEquals("", out.stdErr); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "sleep_one_minute-4").toString(), out.stdOut); + } + + @Test + void testUpdateNewVersionAlreadyNamed(MockServerClient mock) { + + mock.reset(); + + mock.when( + request().withMethod("GET").withPath("/pipelines") + .withQueryStringParameter("search", "\"sleep_one_minute\""), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipelines": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep" + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // Resolve default version (always fetched now) + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "ver123", + "name": "sleep_one_minute-3", + "isDefault": true + } + }], + "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) + ); + + // Server returns a new version that is already named and set as default (fixed API behavior) + mock.when( + request().withMethod("POST").withPath("/pipelines/217997727159863/versions/ver123"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "newver789", + "name": "sleep_one_minute-4", + "dateCreated": "2023-06-01T10:00:00Z", + "lastUpdated": "2023-06-01T10:00:00Z", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + // No validate or manage calls should be made — server already named the version + ExecOut out = exec(mock, "pipelines", "update", "-n", "sleep_one_minute", "--revision", "new-branch", "--version-id", "ver123"); assertEquals("", out.stdErr); - assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "draft456").toString(), out.stdOut); + assertEquals(new PipelinesUpdated(USER_WORKSPACE_NAME, "sleep_one_minute", "sleep_one_minute-4").toString(), out.stdOut); } @Test @@ -2382,6 +2655,26 @@ void testUpdateNoDraftCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // Resolve default version (always fetched now) + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863/versions") + .withQueryStringParameter("isPublished", "true"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [{ + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "version": { + "id": "ver123", + "name": "v1.0", + "isDefault": true + } + }], + "totalSize": 1 + }""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( request().withMethod("GET").withPath("/pipelines/217997727159863/launch") .withQueryStringParameter("versionId", "ver123"), exactly(1) diff --git a/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java b/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java new file mode 100644 index 00000000..0e610c5e --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java @@ -0,0 +1,154 @@ +/* + * 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.utils; + +import io.seqera.tower.ApiException; +import io.seqera.tower.api.PipelineVersionsApi; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VersionNameHelperTest { + + @Test + void testGenerateNextVersionName_noSuffix() { + assertEquals("pipeline-1", VersionNameHelper.generateNextVersionName("pipeline")); + } + + @Test + void testGenerateNextVersionName_withSuffix() { + assertEquals("pipeline-6", VersionNameHelper.generateNextVersionName("pipeline-5")); + } + + @Test + void testGenerateNextVersionName_dotVersion() { + assertEquals("v1.0-2", VersionNameHelper.generateNextVersionName("v1.0-1")); + } + + @Test + void testGenerateNextVersionName_multipleHyphens() { + assertEquals("my-pipeline-name-4", VersionNameHelper.generateNextVersionName("my-pipeline-name-3")); + } + + @Test + void testGenerateNextVersionName_trailingHyphenNoNumber() { + assertEquals("pipeline--1", VersionNameHelper.generateNextVersionName("pipeline-")); + } + + @Test + void testGenerateNextVersionName_onlyVersionNumber() { + assertEquals("-6", VersionNameHelper.generateNextVersionName("-5")); + } + + @Test + void testGenerateNextVersionName_null() { + assertNull(VersionNameHelper.generateNextVersionName(null)); + } + + @Test + void testGenerateNextVersionName_empty() { + assertEquals("", VersionNameHelper.generateNextVersionName("")); + } + + @Test + void testGenerateValidVersionName_firstCandidateValid() throws ApiException { + // API that accepts all names + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) { + // success — name is valid + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline", 1L, 1L, api); + + assertEquals("pipeline-1", result); + } + + @Test + void testGenerateValidVersionName_incrementsOnConflict() throws ApiException { + Set taken = Set.of("pipeline-1"); + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) throws ApiException { + if (taken.contains(name)) { + throw new ApiException(400, "Name taken"); + } + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline", 1L, 1L, api); + + assertEquals("pipeline-2", result); + } + + @Test + void testGenerateValidVersionName_withExistingSuffix() throws ApiException { + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) { + // success + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline-3", 1L, 1L, api); + + assertEquals("pipeline-4", result); + } + + @Test + void testGenerateValidVersionName_fallsBackToRandom() throws ApiException { + Set validated = new HashSet<>(); + PipelineVersionsApi api = new PipelineVersionsApi() { + @Override + public void validatePipelineVersionName(Long pipelineId, String name, Long wspId) throws ApiException { + validated.add(name); + throw new ApiException(400, "Name taken"); + } + }; + + String result = VersionNameHelper.generateValidVersionName("pipeline", 1L, 1L, api); + + assertTrue(result.startsWith("pipeline-")); + assertEquals(4, result.substring("pipeline-".length()).length()); + assertEquals(10, validated.size()); + } + + @Test + void testGenerateValidVersionName_nullBaseName() throws ApiException { + PipelineVersionsApi api = new PipelineVersionsApi(); + + String result = VersionNameHelper.generateValidVersionName(null, 1L, 1L, api); + + assertTrue(result.startsWith("-")); + } + + @Test + void testGenerateValidVersionName_emptyBaseName() throws ApiException { + PipelineVersionsApi api = new PipelineVersionsApi(); + + String result = VersionNameHelper.generateValidVersionName("", 1L, 1L, api); + + assertTrue(result.startsWith("-")); + } +} From 03ab1854594923dd487bb620eae2d1e37bbcf056 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Mon, 23 Feb 2026 22:15:02 +0100 Subject: [PATCH 26/43] refactor: clarify filter parameter --- .../seqera/tower/cli/commands/pipelines/versions/ListCmd.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a0669bc8..d45f1f9c 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 @@ -49,10 +49,10 @@ public class ListCmd extends AbstractPipelinesCmd { @CommandLine.Mixin WorkspaceOptionalOptions workspaceOptions; - @CommandLine.Option(names = {"-f", "--filter"}, description = "Show only pipeline versions with name that contain the given word") + @CommandLine.Option(names = {"-f", "--filter"}, description = "Search pipeline versions by name. Supports special keywords like 'is:default' or 'is:draft'.") 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) + @CommandLine.Option(names = {"--is-published"}, description = "Show only published pipeline versions if true, draft versions only if false, all versions by default") Boolean isPublishedOption = null; @CommandLine.Option(names = {"--full-hash"}, description = "Show full-length hash values without truncation") From 12ec5c0bacfd47f9b4215861281822c0df1e695a Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Mon, 23 Feb 2026 22:15:23 +0100 Subject: [PATCH 27/43] refactor: add version ID to the version list output --- .../pipelines/versions/ListPipelineVersionsCmdResponse.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 6e4cc5f4..4da9c7d9 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 @@ -66,11 +66,12 @@ public void toString(PrintWriter out) { return; } - TableList table = new TableList(out, 5, "Name", "IsDefault", "Hash", "Creator", "Created At"); + TableList table = new TableList(out, 6, "ID", "Name", "IsDefault", "Hash", "Creator", "Created At"); versions.stream() .sorted(Comparator.comparing(PipelineVersionFullInfoDto::getDateCreated)) .forEach(version -> table.addRow( + version.getId(), version.getName(), version.getIsDefault() ? "yes" : "no", showFullHash ? version.getHash() : FormatHelper.formatLargeStringWithEllipsis(version.getHash(), 40), From bf0a97a8370b1ddfcd5d4af503a9a2c6547d51ea Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Mon, 23 Feb 2026 22:15:54 +0100 Subject: [PATCH 28/43] test: cleanup test comments [ci skip] --- .../seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java | 6 ------ 1 file changed, 6 deletions(-) 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 d20ed7ce..44a0d32b 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -155,8 +155,6 @@ private void mockManageVersion(MockServerClient mock, String expectedBody) { } // --- 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) @@ -331,8 +329,6 @@ void testListVersionsWithFullHash(MockServerClient mock) { } // --- 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) @@ -407,8 +403,6 @@ null, PIPELINE_ID, PIPELINE_NAME, allVersions().get(2) } // --- 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) From c6f499f56cbd8cd0313301cda3c57b2c7d6ad142 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 25 Feb 2026 19:02:30 +0100 Subject: [PATCH 29/43] refactor: remove confusing http return code handling --- .../pipelines/versions/{UpdateCmd.java => ManageCmd.java} | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) rename src/main/java/io/seqera/tower/cli/commands/pipelines/versions/{UpdateCmd.java => ManageCmd.java} (92%) 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/ManageCmd.java similarity index 92% rename from src/main/java/io/seqera/tower/cli/commands/pipelines/versions/UpdateCmd.java rename to src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java index 29d9884c..4b69e29f 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/ManageCmd.java @@ -81,11 +81,8 @@ protected Response exec() throws ApiException { 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", resolvedVersionId, ResponseHelper.decodeMessage(e)) + String.format("Unable to manage pipeline version '%s': %s", resolvedVersionId, ResponseHelper.decodeMessage(e)) ); } From c1f25a30b3a48cf01043fe1beb2f061f4614d383 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 25 Feb 2026 19:03:39 +0100 Subject: [PATCH 30/43] refactor: rename versions update cmd to versions manage --- conf/reflect-config.json | 2 +- .../pipelines/versions/ManageCmd.java | 22 +++++----- .../pipelines/versions/VersionsCmd.java | 2 +- .../UpdatePipelineVersionCmdResponse.java | 43 ------------------- .../pipelines/PipelineVersionsCmdTest.java | 26 +++++------ 5 files changed, 26 insertions(+), 69 deletions(-) delete mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/versions/UpdatePipelineVersionCmdResponse.java diff --git a/conf/reflect-config.json b/conf/reflect-config.json index 6da6a52d..e7f01d85 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -2254,7 +2254,7 @@ "queryAllDeclaredConstructors":true }, { - "name":"io.seqera.tower.cli.responses.pipelines.versions.UpdatePipelineVersionCmdResponse", + "name":"io.seqera.tower.cli.responses.pipelines.versions.ManagePipelineVersionCmdResponse", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java index 4b69e29f..43a135df 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ 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.responses.pipelines.versions.ManagePipelineVersionCmdResponse; import io.seqera.tower.cli.utils.ResponseHelper; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineVersionManageRequest; @@ -31,10 +31,10 @@ import picocli.CommandLine.Command; @Command( - name = "update", - description = "Update a pipeline version name or default flag" + name = "manage", + description = "Manage a pipeline version name or default version status" ) -public class UpdateCmd extends AbstractPipelinesCmd { +public class ManageCmd extends AbstractPipelinesCmd { @CommandLine.Mixin PipelineRefOptions pipelineRefOptions; @@ -46,10 +46,10 @@ public class UpdateCmd extends AbstractPipelinesCmd { VersionRefOptions versionRefOptions; @CommandLine.ArgGroup(exclusive = false, multiplicity = "1", - heading = "%nUpdate options (at least one required):%n") - public UpdateOptions updateOptions; + heading = "%nManage options (at least one required):%n") + public ManageOptions manageOptions; - public static class UpdateOptions { + public static class ManageOptions { @CommandLine.Option(names = {"--new-name"}, description = "New name for the pipeline version") public String name; @@ -70,8 +70,8 @@ protected Response exec() throws ApiException { String resolvedVersionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); PipelineVersionManageRequest request = new PipelineVersionManageRequest() - .name(updateOptions.name) - .isDefault(updateOptions.isDefault); + .name(manageOptions.name) + .isDefault(manageOptions.isDefault); try { pipelineVersionsApi().managePipelineVersion( @@ -86,6 +86,6 @@ protected Response exec() throws ApiException { ); } - return new UpdatePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), resolvedVersionId); + return new ManagePipelineVersionCmdResponse(workspaceOptions.workspace, pipeline.getPipelineId(), pipeline.getName(), resolvedVersionId); } } 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 e59535f5..6b60a6b0 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 @@ -27,7 +27,7 @@ subcommands = { ListCmd.class, ViewCmd.class, - UpdateCmd.class, + ManageCmd.class, } ) public class VersionsCmd extends AbstractRootCmd { 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 deleted file mode 100644 index 42884f0e..00000000 --- a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/UpdatePipelineVersionCmdResponse.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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)); - } -} 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 44a0d32b..a17c6dc3 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ 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.ManagePipelineVersionCmdResponse; import io.seqera.tower.cli.responses.pipelines.versions.ViewPipelineVersionCmdResponse; import io.seqera.tower.cli.utils.PaginationInfo; import io.seqera.tower.model.PipelineVersionFullInfoDto; @@ -402,7 +402,7 @@ null, PIPELINE_ID, PIPELINE_NAME, allVersions().get(2) ).toString()), out.stdOut); } - // --- Update command tests --- + // --- Manage command tests --- @ParameterizedTest @EnumSource(OutputType.class) @@ -412,10 +412,10 @@ void testUpdateVersionName(OutputType format, MockServerClient mock) { mockPipelineDescribe(mock); mockManageVersion(mock, "{\"name\":\"new-version-name\"}"); - ExecOut out = exec(format, mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), + ExecOut out = exec(format, mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), "--version-id", VERSION_ID_V1, "--new-name", "new-version-name"); - assertOutput(format, out, new UpdatePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1)); + assertOutput(format, out, new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1)); } @Test @@ -425,12 +425,12 @@ void testUpdateVersionSetDefault(MockServerClient mock) { mockPipelineDescribe(mock); mockManageVersion(mock, "{\"isDefault\":true}"); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), "--version-id", VERSION_ID_V1, "--set-default"); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); - assertEquals(new UpdatePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + assertEquals(new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); } @Test @@ -441,12 +441,12 @@ void testUpdateVersionByPipelineName(MockServerClient mock) { mockPipelineDescribe(mock); mockManageVersion(mock, "{\"name\":\"renamed-version\"}"); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-n", PIPELINE_NAME, + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-n", PIPELINE_NAME, "--version-id", VERSION_ID_V1, "--new-name", "renamed-version"); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); - assertEquals(new UpdatePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + assertEquals(new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); } @Test @@ -469,12 +469,12 @@ void testUpdateVersionByVersionName(MockServerClient mock) { mockManageVersion(mock, "{\"name\":\"renamed\"}"); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), "--version-name", "TestVersioningInUserWsp-1", "--new-name", "renamed"); assertEquals("", out.stdErr); assertEquals(0, out.exitCode); - assertEquals(new UpdatePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); + assertEquals(new ManagePipelineVersionCmdResponse(null, PIPELINE_ID, PIPELINE_NAME, VERSION_ID_V1).toString(), out.stdOut); } @Test @@ -493,7 +493,7 @@ void testUpdateVersionInvalidName(MockServerClient mock) { .withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-i", PIPELINE_ID.toString(), + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-i", PIPELINE_ID.toString(), "--version-id", VERSION_ID_V1, "--new-name", "!invalid!"); assertEquals(1, out.exitCode); @@ -516,7 +516,7 @@ void testUpdateVersionPipelineNotFound(MockServerClient mock) { .withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "versions", "update", "-n", "nonexistent", + ExecOut out = exec(mock, "pipelines", "versions", "manage", "-n", "nonexistent", "--version-id", VERSION_ID_V1, "--new-name", "new-name"); assertEquals(errorMessage(out.app, new PipelineNotFoundException("\"nonexistent\"", USER_WORKSPACE_NAME)), out.stdErr); From b07f992fbd27e2b1dba9612a18bacf665187bf11 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 25 Feb 2026 19:04:05 +0100 Subject: [PATCH 31/43] fix: license header --- .../seqera/tower/cli/commands/pipelines/versions/ListCmd.java | 2 +- .../cli/commands/pipelines/versions/VersionRefOptions.java | 2 +- .../tower/cli/commands/pipelines/versions/VersionsCmd.java | 2 +- .../seqera/tower/cli/commands/pipelines/versions/ViewCmd.java | 2 +- .../pipelines/versions/ListPipelineVersionsCmdResponse.java | 2 +- .../pipelines/versions/ViewPipelineVersionCmdResponse.java | 2 +- src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java | 2 +- .../java/io/seqera/tower/cli/utils/VersionNameHelperTest.java | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) 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 d45f1f9c..ca061085 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,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 index 702ef26d..85092cd6 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 6b60a6b0..160ee512 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,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 0263fecb..8a8031b7 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 4da9c7d9..5055f573 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,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 index a10d695e..7b6ce572 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java index 5a370058..e9e181fd 100644 --- a/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java b/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java index 0e610c5e..914c43ba 100644 --- a/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java +++ b/src/test/java/io/seqera/tower/cli/utils/VersionNameHelperTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023, Seqera. + * Copyright 2021-2026, Seqera. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 178d579128e3bd8e38fb64a29933aee9bca79d39 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 25 Feb 2026 19:24:36 +0100 Subject: [PATCH 32/43] chore: update filter flag description with correct keywords, add extra tests to cover the use case --- .../commands/pipelines/versions/ListCmd.java | 2 +- .../pipelines/PipelineVersionsCmdTest.java | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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 ca061085..548b2350 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 @@ -49,7 +49,7 @@ public class ListCmd extends AbstractPipelinesCmd { @CommandLine.Mixin WorkspaceOptionalOptions workspaceOptions; - @CommandLine.Option(names = {"-f", "--filter"}, description = "Search pipeline versions by name. Supports special keywords like 'is:default' or 'is:draft'.") + @CommandLine.Option(names = {"-f", "--filter"}, description = "Search pipeline versions by name prefix. Also supports keyword filters: versionName, versionId, versionHash. Multiple filters can be combined e.g. 'myPipeline versionName: versionHash:'.") public String filter; @CommandLine.Option(names = {"--is-published"}, description = "Show only published pipeline versions if true, draft versions only if false, all versions by default") 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 a17c6dc3..05adec44 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -239,6 +239,30 @@ void testListVersionsWithFilter(MockServerClient mock) { assertEquals(0, out.exitCode); } + @Test + void testListVersionsWithMultipleFilterKeywords(MockServerClient mock) { + + mock.reset(); + mockPipelineDescribe(mock); + + mock.when( + request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") + .withQueryStringParameter("search", "TestVersioningInUserWsp versionHash:" + HASH_V1) + .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", "-i", PIPELINE_ID.toString(), + "-f", "TestVersioningInUserWsp versionHash:" + HASH_V1); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + } + @Test void testListVersionsWithPagination(MockServerClient mock) { From 9d5e3022954c2ebd22f01cc937cf7986e08e01cb Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Wed, 25 Feb 2026 20:04:14 +0100 Subject: [PATCH 33/43] chore: remove unnecessary pipeline null checks --- .../cli/commands/pipelines/AbstractPipelinesCmd.java | 7 ------- .../tower/cli/commands/pipelines/versions/ListCmd.java | 8 -------- .../tower/cli/commands/pipelines/versions/ManageCmd.java | 4 ---- .../tower/cli/commands/pipelines/versions/ViewCmd.java | 4 ---- .../tower/cli/pipelines/PipelineVersionsCmdTest.java | 6 ++---- 5 files changed, 2 insertions(+), 27 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 b05a49d8..4c059589 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 @@ -62,13 +62,6 @@ 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 548b2350..befaae68 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 @@ -22,7 +22,6 @@ 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; @@ -67,16 +66,9 @@ 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, diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java index 43a135df..1bdcfff8 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ManageCmd.java @@ -63,10 +63,6 @@ protected Response exec() throws ApiException { Long wspId = workspaceId(workspaceOptions.workspace); PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); - if (pipeline == null) { - throwPipelineNotFoundException(pipelineRefOptions, wspId); - } - String resolvedVersionId = resolvePipelineVersionId(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); PipelineVersionManageRequest request = new PipelineVersionManageRequest() 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 8a8031b7..254e0c3f 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 @@ -50,10 +50,6 @@ protected Response exec() throws ApiException { Long wspId = workspaceId(workspaceOptions.workspace); PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); - if (pipeline == null) { - throwPipelineNotFoundException(pipelineRefOptions, wspId); - } - PipelineVersionFullInfoDto version = findPipelineVersionByRef(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); if (version == 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 05adec44..13750ea9 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -224,8 +224,7 @@ void testListVersionsWithFilter(MockServerClient mock) { mock.when( request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") - .withQueryStringParameter("search", "TestVersioningInUserWsp-1") - .withQueryStringParameter("isPublished", "true"), + .withQueryStringParameter("search", "TestVersioningInUserWsp-1"), exactly(1) ).respond( response().withStatusCode(200) @@ -247,8 +246,7 @@ void testListVersionsWithMultipleFilterKeywords(MockServerClient mock) { mock.when( request().withMethod("GET").withPath("/pipelines/" + PIPELINE_ID + "/versions") - .withQueryStringParameter("search", "TestVersioningInUserWsp versionHash:" + HASH_V1) - .withQueryStringParameter("isPublished", "true"), + .withQueryStringParameter("search", "TestVersioningInUserWsp versionHash:" + HASH_V1), exactly(1) ).respond( response().withStatusCode(200) From b5a7677b0f9cef7363103fca0b13915eda959a60 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 17:30:30 +0100 Subject: [PATCH 34/43] chore: remove unnecessary feature check --- .../seqera/tower/cli/commands/pipelines/versions/ListCmd.java | 4 ---- 1 file changed, 4 deletions(-) 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 befaae68..14e428c3 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 @@ -77,10 +77,6 @@ protected Response exec() throws ApiException { 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) From a20c9e01a18cf23a44e1d06d12cba362c3e8bc85 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 17:36:40 +0100 Subject: [PATCH 35/43] chore: remove references to frontend files --- .../io/seqera/tower/cli/commands/pipelines/UpdateCmd.java | 4 ++-- 1 file changed, 2 insertions(+), 2 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 d56d000e..1fbe8690 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 @@ -118,7 +118,7 @@ protected Response exec() throws ApiException, IOException { // - Server already named it (fixed API) → just report it. // - Unnamed draft + --allow-draft → report draft ID, let user manage it manually. // - Unnamed draft (default behavior) → auto-generate a name, assign it, and promote to default - // (mirrors the frontend naming algorithm from version-name-resolver.ts). + // (mirrors the frontend auto-naming algorithm). return handleVersioningResult(response, target, pipe, wspId); } @@ -272,7 +272,7 @@ private Response handleVersioningResult( /** * Names an unnamed draft version and promotes it to default. This mirrors the frontend's - * behavior in version-name-resolver.ts: derive an incremental name from the current default + * behavior: derive an incremental name from the current default * version name, validate it against the server (to avoid collisions), and assign it. */ private String autoNameAndPromote( From 857db4cc6cf219015a12e122273c2990264ff6da Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 17:44:01 +0100 Subject: [PATCH 36/43] chore: change access modifier for version name generator method --- src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java index e9e181fd..38e4d91c 100644 --- a/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java +++ b/src/main/java/io/seqera/tower/cli/utils/VersionNameHelper.java @@ -44,7 +44,7 @@ private VersionNameHelper() { * Strips trailing -{number}, increments (or starts at 1). * E.g., "pipeline-3" → "pipeline-4", "rnaseq" → "rnaseq-1" */ - public static String generateNextVersionName(String baseName) { + protected static String generateNextVersionName(String baseName) { if (baseName == null || baseName.isEmpty()) { return baseName; } From dfeb3e2b11bc6961a4f752956e7e8920a51c653d Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 19:08:54 +0100 Subject: [PATCH 37/43] fix: missing response class --- .../ManagePipelineVersionCmdResponse.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java diff --git a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java new file mode 100644 index 00000000..b0f3b550 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ManagePipelineVersionCmdResponse.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.responses.pipelines.versions; + +import io.seqera.tower.cli.responses.Response; + +public class ManagePipelineVersionCmdResponse extends Response { + + public final String workspaceRef; + public final Long pipelineId; + public final String pipelineName; + public final String versionId; + + public ManagePipelineVersionCmdResponse(String workspaceRef, Long pipelineId, String pipelineName, String versionId) { + this.workspaceRef = workspaceRef; + this.pipelineId = pipelineId; + this.pipelineName = pipelineName; + this.versionId = versionId; + } + + @Override + public String toString() { + if (workspaceRef != null) { + return ansi(String.format("%n @|yellow Pipeline version '%s' of pipeline '%s' updated at workspace %s|@%n", versionId, pipelineName, workspaceRef)); + } + return ansi(String.format("%n @|yellow Pipeline version '%s' of pipeline '%s' updated|@%n", versionId, pipelineName)); + } +} From 14ce21c7b3119629aa0344840940ecd92829eef7 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 19:09:19 +0100 Subject: [PATCH 38/43] fix: remove unused test case --- .../pipelines/PipelineVersionsCmdTest.java | 22 ------------------- 1 file changed, 22 deletions(-) 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 13750ea9..afa8f636 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -311,28 +311,6 @@ void testListVersionsPipelineNotFound(MockServerClient mock) { 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) { From 6dd228c480646e764d71c5c32ea47551e9f827c3 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 19:10:05 +0100 Subject: [PATCH 39/43] chore: remove unnecessary feature check --- .../tower/cli/commands/pipelines/UpdateCmd.java | 13 +++---------- .../cli/commands/pipelines/versions/ListCmd.java | 1 - 2 files changed, 3 insertions(+), 11 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 1fbe8690..5aa7026c 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 @@ -20,7 +20,6 @@ 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.exceptions.TowerException; import io.seqera.tower.cli.responses.Response; import io.seqera.tower.cli.responses.pipelines.PipelinesUpdated; import io.seqera.tower.cli.utils.FilesHelper; @@ -162,22 +161,16 @@ private static class VersionTarget { private VersionTarget resolveVersionTarget(PipelineDbDto pipe, Long wspId) throws ApiException { // Always fetch the default version eagerly — we need its name for auto-naming if a draft // is created, regardless of whether we're updating the default or a user-specified version. - // Falls back to the pipeline name if no default version exists yet. PipelineVersionFullInfoDto defaultVersion = fetchDefaultVersion(pipe.getPipelineId(), wspId); - String defaultVersionName = defaultVersion != null ? defaultVersion.getName() : pipe.getName(); if (versionRef != null) { // User explicitly targeted a version via --version-id or --version-name String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); - return new VersionTarget(versionId, defaultVersionName); + return new VersionTarget(versionId, defaultVersion.getName()); } - // No explicit version — target the default. Every pipeline should have one, but guard - // against edge cases (e.g. pipeline just created, no versions published yet). - if (defaultVersion == null) { - throw new TowerException(String.format("No default version found for pipeline '%s'", pipe.getName())); - } - return new VersionTarget(defaultVersion.getId(), defaultVersionName); + // No explicit version — target the default version + return new VersionTarget(defaultVersion.getId(), defaultVersion.getName()); } // --- Launch and request building --- 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 14e428c3..4c714b56 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 @@ -22,7 +22,6 @@ 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.ListPipelineVersionsCmdResponse; import io.seqera.tower.cli.utils.PaginationInfo; From 246f1bb97eb17d34d938ed378fdcd9842e62466f Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 19:55:24 +0100 Subject: [PATCH 40/43] refactor: simplify default pipeline version fetch --- .../cli/commands/pipelines/UpdateCmd.java | 39 ++-- .../tower/cli/pipelines/PipelinesCmdTest.java | 212 ++++++++++-------- 2 files changed, 137 insertions(+), 114 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 5aa7026c..c68cee63 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/UpdateCmd.java @@ -25,7 +25,6 @@ import io.seqera.tower.cli.utils.FilesHelper; import io.seqera.tower.cli.utils.VersionNameHelper; import io.seqera.tower.model.LaunchDbDto; -import io.seqera.tower.model.ListPipelineVersionsResponse; import io.seqera.tower.model.PipelineDbDto; import io.seqera.tower.model.PipelineVersionFullInfoDto; import io.seqera.tower.model.PipelineVersionManageRequest; @@ -38,7 +37,6 @@ import picocli.CommandLine.Option; import java.io.IOException; -import java.util.Collections; import static io.seqera.tower.cli.utils.ModelHelper.coalesce; import static io.seqera.tower.cli.utils.ModelHelper.removeEmptyValues; @@ -82,8 +80,8 @@ protected Response exec() throws ApiException, IOException { Long wspId = workspaceId(workspace.workspace); - // Resolve the pipeline by --id or --name so we have its metadata (name, description, etc.) - PipelineDbDto pipe = fetchPipeline(wspId); + // Fetch uses describePipeline, which returns the default version on pipe.getVersion() + PipelineDbDto pipe = fetchPipeline(pipelineRefOptions, wspId); // If the user wants to rename the pipeline (--new-name), validate early before making any // changes. The server checks uniqueness within the workspace/org scope. @@ -124,11 +122,10 @@ protected Response exec() throws ApiException, IOException { // --- Pipeline resolution --- private PipelineDbDto fetchPipeline(Long wspId) throws ApiException { - if (pipelineRefOptions.pipeline.pipelineId != null) { - Long id = pipelineRefOptions.pipeline.pipelineId; - return pipelinesApi().describePipeline(id, Collections.emptyList(), wspId, null).getPipeline(); - } - return pipelineByName(wspId, pipelineRefOptions.pipeline.pipelineName); + // Always go through describePipeline (not just the search/list endpoint) because + // describePipeline returns the default version on pipe.getVersion() — we need it + // later for auto-naming. The parent's fetchPipeline resolves name→id first if needed. + return fetchPipeline(pipelineRefOptions, wspId); } private void validateNewName(String newName, Long wspId) throws ApiException { @@ -159,18 +156,20 @@ private static class VersionTarget { } private VersionTarget resolveVersionTarget(PipelineDbDto pipe, Long wspId) throws ApiException { - // Always fetch the default version eagerly — we need its name for auto-naming if a draft - // is created, regardless of whether we're updating the default or a user-specified version. - PipelineVersionFullInfoDto defaultVersion = fetchDefaultVersion(pipe.getPipelineId(), wspId); + // describePipeline (called without a versionId) already returns the default version + // on pipe.getVersion() — no need for a separate listPipelineVersions call. + // We need the default version's name for auto-naming if a draft is created later. + PipelineVersionFullInfoDto defaultVersion = pipe.getVersion(); + String defaultVersionName = defaultVersion.getName(); if (versionRef != null) { // User explicitly targeted a version via --version-id or --version-name String versionId = resolvePipelineVersionId(pipe.getPipelineId(), wspId, versionRef); - return new VersionTarget(versionId, defaultVersion.getName()); + return new VersionTarget(versionId, defaultVersionName); } // No explicit version — target the default version - return new VersionTarget(defaultVersion.getId(), defaultVersion.getName()); + return new VersionTarget(defaultVersion.getId(), defaultVersionName); } // --- Launch and request building --- @@ -297,16 +296,4 @@ private PipelineVersionFullInfoDto detectNewVersion(UpdatePipelineResponse respo return version.getId().equals(requestedVersionId) ? null : version; } - private PipelineVersionFullInfoDto fetchDefaultVersion(Long pipelineId, Long wspId) throws ApiException { - // Query published versions and find the one marked as default. We use the published filter - // because draft versions should not be considered as the "current default" for naming. - ListPipelineVersionsResponse versionsResponse = pipelineVersionsApi() - .listPipelineVersions(pipelineId, wspId, null, null, null, true); - if (versionsResponse.getVersions() == null) return null; - return versionsResponse.getVersions().stream() - .map(PipelineDbDto::getVersion) - .filter(v -> v != null && Boolean.TRUE.equals(v.getIsDefault())) - .findFirst() - .orElse(null); - } } 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 31716b94..3a83d216 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -80,12 +80,23 @@ void testUpdate(MockServerClient mock) { response().withStatusCode(200).withBody("{\"pipelines\":[{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON) ); + // describePipeline returns the default version on pipe.getVersion() mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" - {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -121,11 +132,21 @@ void testUpdateComputeEnv(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" - {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -173,17 +194,27 @@ void testUpdatePipelineName(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "sleepOneMinute") + request().withMethod("GET").withPath("/pipelines/217997727159863") ).respond( - response().withStatusCode(204) + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "sleepOneMinute") ).respond( - response().withStatusCode(200).withBody(""" - {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(204) ); mock.when( @@ -222,11 +253,21 @@ void testUpdateWithCommitId(MockServerClient mock) { ); mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" - {"versions":[{"pipelineId":217997727159863,"name":"sleep_one_minute","version":{"id":"default-ver","name":"sleep_one_minute-1","isDefault":true}}],"totalSize":1}""").withContentType(MediaType.APPLICATION_JSON) + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -263,6 +304,24 @@ void testUpdatePipelineInvalidName(MockServerClient mock) { response().withStatusCode(200).withBody("{\"pipelines\":[{\"pipelineId\":217997727159863,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":4,\"userName\":\"jordi\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null}],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON) ); + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863") + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( request().withMethod("GET").withPath("/pipelines/validate").withQueryStringParameter("name", "#sleep") ).respond( @@ -304,17 +363,7 @@ void testUpdateInSharedWorkspaceWithoutCE(MockServerClient mock) throws IOExcept ).respond( response().withStatusCode(200) .withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/nextflow-io/hello\",\"userId\":312,\"userName\":\"user\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":44406019030987,\"orgName\":\"test-cli-org\",\"workspaceId\":59563405657242,\"workspaceName\":\"SharedWS\",\"visibility\":\"SHARED\",\"deleted\":false,\"lastUpdated\":\"2024-11-12T13:27:32Z\",\"optimizationId\":null,\"optimizationTargets\":null,\"optimizationStatus\":null,\"labels\":null,\"computeEnv\":null}}\n") - ); - - mock.when( - request().withMethod("GET").withPath("/pipelines/68359275903286/versions") - .withQueryStringParameter("workspaceId", "59563405657242") - .withQueryStringParameter("isPublished", "true"), exactly(1) - ).respond( - response().withStatusCode(200) - .withContentType(MediaType.APPLICATION_JSON) - .withBody("{\"versions\":[{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"version\":{\"id\":\"default-ver\",\"name\":\"hello-pipeline-1\",\"isDefault\":true}}],\"totalSize\":1}") + .withBody("{\"pipeline\":{\"pipelineId\":68359275903286,\"name\":\"hello-pipeline\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/nextflow-io/hello\",\"userId\":312,\"userName\":\"user\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":44406019030987,\"orgName\":\"test-cli-org\",\"workspaceId\":59563405657242,\"workspaceName\":\"SharedWS\",\"visibility\":\"SHARED\",\"deleted\":false,\"lastUpdated\":\"2024-11-12T13:27:32Z\",\"optimizationId\":null,\"optimizationTargets\":null,\"optimizationStatus\":null,\"labels\":null,\"computeEnv\":null,\"version\":{\"id\":\"default-ver\",\"name\":\"hello-pipeline-1\",\"isDefault\":true}}}\n") ); mock.when( @@ -2101,23 +2150,22 @@ void testUpdateWithVersionId(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version (always fetched now) + // describePipeline returns the default version on pipe.getVersion() mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "ver123", "name": "sleep_one_minute-1", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); @@ -2174,23 +2222,21 @@ void testUpdateWithVersionName(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version (always fetched now) mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "default-ver", "name": "sleep_one_minute-1", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); @@ -2273,23 +2319,21 @@ void testUpdateWithVersionNameNotFound(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version (always fetched now) mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "default-ver", "name": "sleep_one_minute-1", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); @@ -2332,23 +2376,22 @@ void testUpdateDraftVersionCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version ID + provide default version name for auto-naming + // describePipeline returns the default version — its name is used for auto-naming mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "default-ver", "name": "sleep_one_minute-1", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); @@ -2419,23 +2462,21 @@ void testUpdateDraftVersionCreatedWithAllowDraft(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version ID mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "default-ver", "name": "sleep_one_minute-1", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); @@ -2493,6 +2534,25 @@ void testUpdateVersionWithDraftCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); + // describePipeline returns default version name "sleep_one_minute-3" for auto-naming + mock.when( + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 217997727159863, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "version": { + "id": "ver123", + "name": "sleep_one_minute-3", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( request().withMethod("GET").withPath("/pipelines/217997727159863/launch") .withQueryStringParameter("versionId", "ver123"), exactly(1) @@ -2518,26 +2578,6 @@ void testUpdateVersionWithDraftCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Mock listPipelineVersions to return a default version - mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) - ).respond( - response().withStatusCode(200).withBody(""" - { - "versions": [{ - "pipelineId": 217997727159863, - "name": "sleep_one_minute", - "version": { - "id": "ver123", - "name": "sleep_one_minute-3", - "isDefault": true - } - }], - "totalSize": 1 - }""").withContentType(MediaType.APPLICATION_JSON) - ); - // Mock validatePipelineVersionName mock.when( request().withMethod("GET").withPath("/pipelines/217997727159863/versions/validate") @@ -2579,23 +2619,21 @@ void testUpdateNewVersionAlreadyNamed(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version (always fetched now) mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "ver123", "name": "sleep_one_minute-3", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); @@ -2654,23 +2692,21 @@ void testUpdateNoDraftCreated(MockServerClient mock) { }""").withContentType(MediaType.APPLICATION_JSON) ); - // Resolve default version (always fetched now) mock.when( - request().withMethod("GET").withPath("/pipelines/217997727159863/versions") - .withQueryStringParameter("isPublished", "true"), exactly(1) + request().withMethod("GET").withPath("/pipelines/217997727159863"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { - "versions": [{ + "pipeline": { "pipelineId": 217997727159863, "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", "version": { "id": "ver123", "name": "v1.0", "isDefault": true } - }], - "totalSize": 1 + } }""").withContentType(MediaType.APPLICATION_JSON) ); From 8e30f7e2f8e0ee354713d6bfc44162fbd94ba0e8 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 20:25:26 +0100 Subject: [PATCH 41/43] refactor: remove pipeline versions view cmd, info already available with pipelines view cmd --- conf/reflect-config.json | 6 -- .../pipelines/versions/VersionsCmd.java | 1 - .../commands/pipelines/versions/ViewCmd.java | 62 --------------- .../ViewPipelineVersionCmdResponse.java | 63 --------------- .../pipelines/PipelineVersionsCmdTest.java | 76 ------------------- 5 files changed, 208 deletions(-) delete mode 100644 src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java delete mode 100644 src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ViewPipelineVersionCmdResponse.java diff --git a/conf/reflect-config.json b/conf/reflect-config.json index e7f01d85..dbf84085 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -2259,12 +2259,6 @@ "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.pipelineschemas.PipelineSchemasAdded", "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 160ee512..d75b3120 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,7 +26,6 @@ description = "Manage pipeline versions", subcommands = { ListCmd.class, - ViewCmd.class, ManageCmd.class, } ) 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 deleted file mode 100644 index 254e0c3f..00000000 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/versions/ViewCmd.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2021-2026, Seqera. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package io.seqera.tower.cli.commands.pipelines.versions; - -import io.seqera.tower.ApiException; -import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; -import io.seqera.tower.cli.commands.pipelines.AbstractPipelinesCmd; -import io.seqera.tower.cli.commands.pipelines.PipelineRefOptions; -import io.seqera.tower.cli.exceptions.TowerException; -import io.seqera.tower.cli.responses.Response; -import io.seqera.tower.cli.responses.pipelines.versions.ViewPipelineVersionCmdResponse; -import io.seqera.tower.model.PipelineDbDto; -import io.seqera.tower.model.PipelineVersionFullInfoDto; -import picocli.CommandLine; -import picocli.CommandLine.Command; - -@Command( - name = "view", - description = "View pipeline version details" -) -public class ViewCmd extends AbstractPipelinesCmd { - - @CommandLine.Mixin - PipelineRefOptions pipelineRefOptions; - - @CommandLine.Mixin - WorkspaceOptionalOptions workspaceOptions; - - @CommandLine.Mixin - VersionRefOptions versionRefOptions; - - @Override - protected Response exec() throws ApiException { - - Long wspId = workspaceId(workspaceOptions.workspace); - PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId); - - PipelineVersionFullInfoDto version = findPipelineVersionByRef(pipeline.getPipelineId(), wspId, versionRefOptions.versionRef); - - if (version == null) { - 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); - } -} 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 deleted file mode 100644 index 7b6ce572..00000000 --- a/src/main/java/io/seqera/tower/cli/responses/pipelines/versions/ViewPipelineVersionCmdResponse.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2021-2026, Seqera. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package io.seqera.tower.cli.responses.pipelines.versions; - -import io.seqera.tower.cli.responses.Response; -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(); - } -} 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 afa8f636..f38214f6 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelineVersionsCmdTest.java @@ -20,10 +20,8 @@ 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.responses.pipelines.versions.ManagePipelineVersionCmdResponse; -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; @@ -328,80 +326,6 @@ void testListVersionsWithFullHash(MockServerClient mock) { ).toString()), out.stdOut); } - // --- View command tests --- - - @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); - } - // --- Manage command tests --- @ParameterizedTest From e736ee40011ff6632e409dd6b6d9b56200670eb6 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 20:25:26 +0100 Subject: [PATCH 42/43] refactor: remove pipeline versions view cmd, info already available with pipelines view cmd --- .../tower/cli/commands/pipelines/ViewCmd.java | 9 +++- .../responses/pipelines/PipelinesView.java | 17 ++----- .../tower/cli/pipelines/PipelinesCmdTest.java | 47 ++++++++++--------- 3 files changed, 36 insertions(+), 37 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 b7897040..753485ce 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/ViewCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/ViewCmd.java @@ -51,9 +51,10 @@ protected Response exec() throws ApiException { PipelineDbDto pipeline = fetchPipeline(pipelineRefOptions, wspId, PipelineQueryAttribute.labels); Long sourceWorkspaceId = sourceWorkspaceId(wspId, pipeline); - PipelineVersionFullInfoDto version = null; - String versionId = null; + PipelineVersionFullInfoDto version; + String versionId; if (versionRef != null) { + // User explicitly targeted a version via --version-id or --version-name version = findPipelineVersionByRef(pipeline.getPipelineId(), wspId, versionRef); if (version != null) { versionId = version.getId(); @@ -63,6 +64,10 @@ protected Response exec() throws ApiException { } else { throw new TowerException(String.format("Pipeline version '%s' not found", versionRef.versionName)); } + } else { + // No explicit version — describePipeline already returns the default version + version = pipeline.getVersion(); + versionId = version.getId(); } LaunchDbDto launch = pipelinesApi().describePipelineLaunch(pipeline.getPipelineId(), wspId, sourceWorkspaceId, versionId).getLaunch(); 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 ce32ac6f..bcd1eb0e 100644 --- a/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesView.java +++ b/src/main/java/io/seqera/tower/cli/responses/pipelines/PipelinesView.java @@ -20,14 +20,12 @@ 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; @@ -39,17 +37,12 @@ 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) { + public PipelinesView(String workspaceRef, PipelineDbDto info, LaunchDbDto launch, PipelineVersionFullInfoDto version, String baseWorkspaceUrl) { this.workspaceRef = workspaceRef; this.info = info; this.launch = launch; @@ -76,11 +69,9 @@ public void toString(PrintWriter out) { table.addRow("Repository", info.getRepository()); table.addRow("Compute env.", launch.getComputeEnv() == null ? "(not defined)" : launch.getComputeEnv().getName()); table.addRow("Labels", info.getLabels() == null || info.getLabels().isEmpty() ? "No labels found" : formatLabels(info.getLabels())); - 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.addRow("Version Name", version.getName() != null ? version.getName() : "(draft)"); + table.addRow("Version Is Default", version.getIsDefault() != null && version.getIsDefault() ? "yes" : "no"); + table.addRow("Version Hash", version.getHash()); table.print(); out.println(String.format("%n Configuration:%n%n%s%n", configJson.replaceAll("(?m)^", " "))); diff --git a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java index 3a83d216..22713816 100644 --- a/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/pipelines/PipelinesCmdTest.java @@ -1001,10 +1001,29 @@ void testView(MockServerClient mock) throws JsonProcessingException { response().withStatusCode(200).withBody("{\"pipelines\":[{\"pipelineId\":213164477645856,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":1776,\"userName\":\"jordi10\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null,\"deleted\":false,\"lastUpdated\":\"2023-05-15T13:59:19Z\",\"optimized\":null,\"labels\":null,\"computeEnv\":null}],\"totalSize\":1}").withContentType(MediaType.APPLICATION_JSON) ); + // describePipeline returns the default version — shown in output even without --version-id/--version-name mock.when( request().withMethod("GET").withPath("/pipelines/213164477645856").withQueryStringParameter("attributes", "labels"), exactly(1) ).respond( - response().withStatusCode(200).withBody("{\"pipeline\":{\"pipelineId\":213164477645856,\"name\":\"sleep_one_minute\",\"description\":null,\"icon\":null,\"repository\":\"https://github.com/pditommaso/nf-sleep\",\"userId\":1776,\"userName\":\"jordi10\",\"userFirstName\":null,\"userLastName\":null,\"orgId\":null,\"orgName\":null,\"workspaceId\":null,\"workspaceName\":null,\"visibility\":null,\"deleted\":false,\"lastUpdated\":\"2023-05-15T13:59:19Z\",\"optimized\":null,\"labels\":[],\"computeEnv\":null}}").withContentType(MediaType.APPLICATION_JSON) + response().withStatusCode(200).withBody(""" + { + "pipeline": { + "pipelineId": 213164477645856, + "name": "sleep_one_minute", + "repository": "https://github.com/pditommaso/nf-sleep", + "userId": 1776, + "userName": "jordi10", + "deleted": false, + "lastUpdated": "2023-05-15T13:59:19Z", + "labels": [], + "version": { + "id": "default-ver", + "name": "sleep_one_minute-1", + "hash": "abc123def456", + "isDefault": true + } + } + }""").withContentType(MediaType.APPLICATION_JSON) ); mock.when( @@ -1019,31 +1038,15 @@ void testView(MockServerClient mock) throws JsonProcessingException { response().withStatusCode(200).withBody(loadResource("user")).withContentType(MediaType.APPLICATION_JSON) ); - ExecOut out = exec(mock, "pipelines", "view", "-n", "sleep_one_minute"); assertEquals("", out.stdErr); - assertEquals(StringUtils.chop(new PipelinesView( - USER_WORKSPACE_NAME, - new PipelineDbDto().pipelineId(213164477645856L).name("sleep_one_minute").repository("https://github.com/pditommaso/nf-sleep"), - new LaunchDbDto() - .id("aB5VzZ5MGKnnAh6xsiKAV") - .pipeline("https://github.com/pditommaso/nf-sleep") - .workDir("$TW_AGENT_WORK") - .paramsText("timeout: 60\n\n") - .dateCreated(OffsetDateTime.parse("2023-05-15T13:59:19Z")) - .lastUpdated(OffsetDateTime.parse("2023-05-15T08:23:29Z")) - .resume(false) - .pullLatest(false) - .stubRun(false) - .computeEnv( - parseJson("{\"id\": \"509cXW9NmIKYTe7KbjxyZn\"}", ComputeEnvComputeConfig.class) - .name("slurm_vallibierna") - ), - baseUserUrl(mock, USER_WORKSPACE_NAME) - ).toString()), out.stdOut - ); assertEquals(0, out.exitCode); + // Default version info should appear even without --version-id/--version-name + assertTrue(out.stdOut.contains("Version Name"), "Output should contain version name row"); + assertTrue(out.stdOut.contains("sleep_one_minute-1"), "Output should contain default version name"); + assertTrue(out.stdOut.contains("Version Is Default"), "Output should contain version default row"); + assertTrue(out.stdOut.contains("abc123def456"), "Output should contain version hash"); } From dff3ecf216e03e2805fae68aea055e7deda73fc9 Mon Sep 17 00:00:00 2001 From: JaimeSeqLabs Date: Thu, 26 Feb 2026 21:11:27 +0100 Subject: [PATCH 43/43] chore: reflection files cleanup --- conf/reflect-config.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index dbf84085..f787b4bb 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1547,18 +1547,6 @@ "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.pipelineschemas.AbstractPipelineSchemasCmd", "allDeclaredFields":true,