Repository: kestra-io/kestra
Issue: [UI] Allow setting custom Execution labels from the Executions page
Labels: enhancement

### Feature description

Related to https://github.com/kestra-io/kestra/issues/2632 

Often, users want to set labels after the fact (e.g. after analyzing an already completed execution). Labels can then be used to: 
- mark failed Executions as "Resolved" after investigating the reason for failure
- set custom labels based on some outcome/output of that Execution 
- It will enable this use case already (even though we will tackle it in a first-class manner later on) https://github.com/kestra-io/kestra/issues/2423 


## Possible usage

Select one or more executions and click on "Set labels":

![image](https://github.com/kestra-io/kestra/assets/86264395/cb64925a-5c3d-4d54-bf20-c98a0f93f9cc)

Then, it can pop up similar input fields as here with key-value pair(s) to enter:
![image](https://github.com/kestra-io/kestra/assets/86264395/13314cf6-45fa-484d-905e-95b822d9ef03)



Issue comments:
The UX workflow:

1. Select one or more executions
2. Click on `Set labels` bulk action
3. Fill in the label key value pairs to add or modify/overwrite from the same "Executions labels" box as in Advanced configuration when you click on the Execute button

![image](https://github.com/kestra-io/kestra/assets/86264395/ddfb4f67-9fbe-4a9b-bd9d-3d3c8f14a819)

Important question: should we limit this functionality to terminated execution?

if not, this may be hard to implement as there will be races with the executor.
yup definitely 👍 

during execution, labels can already be overwritten by the Labels task so good point about the race condition. 

Pull request review comments:

Patch that resolved the issue:
diff --git a/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java
index 7cd50e6cdc..d2f061a9ed 100644
--- a/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java
+++ b/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java
@@ -139,7 +139,9 @@ List<ExecutionCount> executionCounts(
         @Nullable ZonedDateTime endDate
     );
 
-    Execution save(Execution flow);
+    Execution save(Execution execution);
+
+    Execution update(Execution execution);
 
     default Function<String, String> sortMapping() throws IllegalArgumentException {
         return s -> s;
diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java
index 4da9e6e705..28fa7afc35 100644
--- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java
+++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java
@@ -852,6 +852,21 @@ public Execution save(DSLContext dslContext, Execution execution) {
         return execution;
     }
 
+    @Override
+    public Execution update(Execution execution) {
+        return this.jdbcRepository
+            .getDslContextWrapper()
+            .transactionResult(configuration -> {
+                DSL.using(configuration)
+                    .update(this.jdbcRepository.getTable())
+                    .set(this.jdbcRepository.persistFields((execution)))
+                    .where(field("key").eq(execution.getId()))
+                    .execute();
+
+                return execution;
+            });
+    }
+
     @SneakyThrows
     @Override
     public Execution delete(Execution execution) {
diff --git a/repository-memory/src/main/java/io/kestra/repository/memory/MemoryExecutionRepository.java b/repository-memory/src/main/java/io/kestra/repository/memory/MemoryExecutionRepository.java
index ba36366950..6b787ca10a 100644
--- a/repository-memory/src/main/java/io/kestra/repository/memory/MemoryExecutionRepository.java
+++ b/repository-memory/src/main/java/io/kestra/repository/memory/MemoryExecutionRepository.java
@@ -93,6 +93,11 @@ public Execution save(Execution execution) {
         return executions.put(execution.getId(), execution);
     }
 
+    @Override
+    public Execution update(Execution execution) {
+        return executions.put(execution.getId(), execution);
+    }
+
     @Override
     public Map<String, Map<String, List<DailyExecutionStatistics>>> dailyGroupByFlowStatistics(
         @Nullable String query,
diff --git a/ui/src/components/executions/Executions.vue b/ui/src/components/executions/Executions.vue
index fa5c38b17f..b87bf5ca3e 100644
--- a/ui/src/components/executions/Executions.vue
+++ b/ui/src/components/executions/Executions.vue
@@ -159,7 +159,33 @@
                             <el-button v-if="canDelete" :icon="Delete" type="default" @click="deleteExecutions()">
                                 {{ $t('delete') }}
                             </el-button>
+                            <el-button v-if="canUpdate" :icon="LabelMultiple" @click="isOpenLabelsModal = !isOpenLabelsModal">
+                                {{ $t('Set labels') }}
+                            </el-button>
                         </bulk-select>
+                        <el-dialog v-if="isOpenLabelsModal" v-model="isOpenLabelsModal" destroy-on-close :append-to-body="true">
+                            <template #header>
+                                <h5>{{ $t("Set labels") }}</h5>
+                            </template>
+
+                            <template #footer>
+                                <el-button @click="isOpenLabelsModal = false">
+                                    {{ $t("cancel") }}
+                                </el-button>
+                                <el-button type="primary" @click="setLabels()">
+                                    {{ $t("ok") }}
+                                </el-button>
+                            </template>
+
+                            <el-form>
+                                <el-form-item :label="$t('execution labels')">
+                                    <label-input
+                                        :key="executionLabels"
+                                        v-model:labels="executionLabels"
+                                    />
+                                </el-form-item>
+                            </el-form>
+                        </el-dialog>
                     </template>
                     <template #default>
                         <el-table-column
@@ -332,6 +358,7 @@
     import Pencil from "vue-material-design-icons/Pencil.vue";
     import Import from "vue-material-design-icons/Import.vue";
     import Utils from "../../utils/utils";
+    import LabelMultiple from "vue-material-design-icons/LabelMultiple.vue";
 </script>
 
 <script>
@@ -362,6 +389,7 @@
     import action from "../../models/action";
     import TriggerFlow from "../../components/flows/TriggerFlow.vue";
     import {storageKeys} from "../../utils/constants";
+    import LabelInput from "../../components/labels/LabelInput.vue";
 
     export default {
         mixins: [RouteContext, RestoreUrl, DataTableActions, SelectTableActions],
@@ -382,7 +410,8 @@
             Labels,
             Id,
             TriggerFlow,
-            TopNavBar
+            TopNavBar,
+            LabelInput
         },
         props: {
             hidden: {
@@ -487,7 +516,9 @@
                 displayColumns: [],
                 childFilter: "ALL",
                 canAutoRefresh: false,
-                storageKey: storageKeys.DISPLAY_EXECUTIONS_COLUMNS
+                storageKey: storageKeys.DISPLAY_EXECUTIONS_COLUMNS,
+                isOpenLabelsModal: false,
+                executionLabels: [],
             };
         },
         created() {
@@ -698,6 +729,39 @@
                     }
                 )
             },
+            setLabels() {
+                this.$toast().confirm(
+                    this.$t("bulk set labels", {"executionCount": this.queryBulkAction ? this.total : this.selection.length}),
+                    () => {
+                        if (this.queryBulkAction) {
+                            return this.$store
+                                .dispatch("execution/querySetLabels",  {
+                                    params: this.loadQuery({
+                                        sort: this.$route.query.sort || "state.startDate:desc",
+                                        state: this.$route.query.state ? [this.$route.query.state] : this.statuses
+                                    }, false),
+                                    data: this.executionLabels
+                                })
+                                .then(r => {
+                                    this.$toast().success(this.$t("Set labels done", {executionCount: r.data.count}));
+                                    this.loadData();
+                                })
+                        } else {
+                            return this.$store
+                                .dispatch("execution/bulkSetLabels", {executionsId: this.selection, executionLabels: this.executionLabels})
+                                .then(r => {
+                                    this.$toast().success(this.$t("Set labels done", {executionCount: r.data.count}));
+                                    this.loadData();
+                                }).catch(e => this.$toast().error(e.invalids.map(exec => {
+                                    return {message: this.$t(exec.message, {executionId: exec.invalidValue})}
+                                }), this.$t(e.message)))
+                        }
+                    },
+                    () => {
+                    }
+                )
+                this.isOpenLabelsModal = false;
+            },
             editFlow() {
                 this.$router.push({
                     name: "flows/update", params: {
diff --git a/ui/src/components/executions/Overview.vue b/ui/src/components/executions/Overview.vue
index 8207639d8a..7c07e2e183 100644
--- a/ui/src/components/executions/Overview.vue
+++ b/ui/src/components/executions/Overview.vue
@@ -5,6 +5,7 @@
                 <crud type="CREATE" permission="EXECUTION" :detail="{executionId: execution.id}" />
             </el-col>
             <el-col :span="12" class="text-end">
+                <setLabels :execution="execution" />
                 <restart is-replay :execution="execution" @follow="forwardEvent('follow', $event)" />
                 <restart :execution="execution" @follow="forwardEvent('follow', $event)" />
                 <resume :execution="execution" />
@@ -70,6 +71,7 @@
     import {mapState} from "vuex";
     import Status from "../Status.vue";
     import Vars from "./Vars.vue";
+    import SetLabels from "./SetLabels.vue";
     import Restart from "./Restart.vue";
     import Resume from "./Resume.vue";
     import Kill from "./Kill.vue";
@@ -84,6 +86,7 @@
         components: {
             Duration,
             Status,
+            SetLabels,
             Restart,
             Vars,
             Resume,
diff --git a/ui/src/components/executions/SetLabels.vue b/ui/src/components/executions/SetLabels.vue
new file mode 100644
index 0000000000..340f0c93aa
--- /dev/null
+++ b/ui/src/components/executions/SetLabels.vue
@@ -0,0 +1,99 @@
+<template>
+    <el-tooltip
+        :persistent="false"
+        transition=""
+        :hide-after="0"
+        :content="$t('Set labels tooltip')"
+        raw-content
+        :placement="tooltipPosition"
+    >
+        <component
+            :is="component"
+            :icon="LabelMultiple"
+            @click="isOpen = !isOpen"
+            :disabled="!enabled"
+        >
+            {{ $t("Set labels") }}
+        </component>
+    </el-tooltip>
+    <el-dialog v-if="isOpen" v-model="isOpen" destroy-on-close :append-to-body="true">
+        <template #header>
+            <h5>{{ $t("Set labels") }}</h5>
+        </template>
+
+        <template #footer>
+            <el-button @click="isOpen = false">
+                {{ $t("cancel") }}
+            </el-button>
+            <el-button type="primary" @click="setLabels()">
+                {{ $t("ok") }}
+            </el-button>
+        </template>
+
+        <p v-html="$t('Set labels to execution', {id: execution.id})" />
+
+        <el-form>
+            <el-form-item :label="$t('execution labels')">
+                <label-input
+                    :key="executionLabels"
+                    v-model:labels="executionLabels"
+                />
+            </el-form-item>
+        </el-form>
+    </el-dialog>
+</template>
+
+<script setup>
+    import LabelMultiple from "vue-material-design-icons/LabelMultiple.vue";
+</script>
+
+<script>
+    import {mapState} from "vuex";
+    import LabelInput from "../../components/labels/LabelInput.vue";
+    import State from "../../utils/state";
+
+    export default {
+        components: {LabelInput,},
+        props: {
+            component: {
+                type: String,
+                default: "el-button"
+            },
+            execution: {
+                type: Object,
+                required: true
+            },
+            tooltipPosition: {
+                type: String,
+                default: "bottom"
+            }
+        },
+        methods: {
+            setLabels() {
+                this.isOpen = false;
+                this.$store.dispatch("execution/setLabels", {
+                    labels: this.executionLabels,
+                    executionId: this.execution.id
+                }).then(response => {
+                    this.$store.commit("execution/setExecution", response.data)
+                    this.$toast().success(this.$t("Set labels done"));
+                })
+            },
+        },
+        computed: {
+            ...mapState("auth", ["user"]),
+            enabled() {
+                if (State.isRunning(this.execution.state.current)) {
+                    return false;
+                }
+                return true;
+            }
+        },
+        data() {
+            return {
+                isOpen: false,
+                executionLabels: [],
+            };
+        },
+    };
+</script>
diff --git a/ui/src/stores/executions.js b/ui/src/stores/executions.js
index 917deb510e..2db9ccf36e 100644
--- a/ui/src/stores/executions.js
+++ b/ui/src/stores/executions.js
@@ -171,6 +171,23 @@ export default {
             }).then(response => {
                 commit("setFilePreview", response.data)
             })
+        },
+        setLabels(_, options) {
+            return this.$http.post(
+                `${apiUrl(this)}/executions/${options.executionId}/labels`,
+                options.labels,
+                {
+                    headers: {
+                        "Content-Type": "application/json"
+                    }
+                })
+        },
+        querySetLabels({_commit}, options) {
+            return this.$http.post(`${apiUrl(this)}/executions/labels/by-query`, options.data, {
+                params: options.params})
+        },
+        bulkSetLabels({_commit}, options) {
+            return this.$http.post(`${apiUrl(this)}/executions/labels/by-ids`,  options)
         }
     },
     mutations: {
diff --git a/ui/src/translations.json b/ui/src/translations.json
index 35ff25e0ed..ebec5b6d51 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -319,7 +319,7 @@
     "bulk kill": "Are you sure you want to kill <code>{executionCount}</code> execution(s)?",
     "selection": {
       "selected": "<strong>{count}</strong> selected",
-      "all": "Select all <em>({count})</em>"
+      "all": "Select all ({count})"
     },
     "cancel": "Cancel",
     "homeDashboard": {
@@ -587,7 +587,12 @@
     "relative end date": "Relative end date",
     "now": "Now",
     "absolute": "Absolute",
-    "backfill": "Backfill"
+    "backfill": "Backfill",
+    "Set labels tooltip": "Set labels to the execution",
+    "Set labels": "Set labels",
+    "Set labels to execution": "Add or update the labels of the execution <code>{id}</code>",
+    "Set labels done": "Successfully set the labels of the execution",
+    "bulk set labels": "Are you sure you want to set labels to <code>{executionCount}</code>  executions(s)?"
   },
   "fr": {
     "id": "Identifiant",
@@ -1165,7 +1170,12 @@
     "pause backfill": "Mettre en pause le backfill",
     "backfill executions": "Backfill d'éxécutions",
     "execute backfill": "Éxécuter le backfill",
-    "backfill": "Backfill"
+    "backfill": "Backfill",
+    "Set labels tooltip": "Ajouter des labels à l'éxécution",
+    "Set labels": "Ajouter des labels",
+    "Set labels to execution": "Ajouter ou mettre à jour des labels à l'éxécution <code>{id}</code>",
+    "Set labels done": "Labels ajoutés avec succès à l'éxécution",
+    "bulk set labels": "Etes-vous sûr de vouloir ajouter des labels à <code>{executionCount}</code>  éxécutions(s)?"
   }
 }
 
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
index c244fa45e1..3de1d242eb 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
@@ -72,6 +72,7 @@
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import jakarta.inject.Inject;
 import jakarta.inject.Named;
+import jakarta.validation.constraints.NotNull;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.experimental.SuperBuilder;
@@ -92,14 +93,9 @@
 import java.nio.charset.UnsupportedCharsetException;
 import java.time.Duration;
 import java.time.ZonedDateTime;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.NoSuchElementException;
-import java.util.Optional;
-import java.util.Set;
+import java.util.*;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
 
 import static io.kestra.core.utils.Rethrow.throwBiFunction;
 import static io.kestra.core.utils.Rethrow.throwFunction;
@@ -1097,4 +1093,135 @@ public HttpResponse<?> filePreview(
             return HttpResponse.ok(fileRender);
         }
     }
+
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "/{executionId}/labels")
+    @Operation(tags = {"Executions"}, summary = "Add or update labels of a terminated execution")
+    @ApiResponse(responseCode = "404", description = "If the execution cannot be found")
+    @ApiResponse(responseCode = "400", description = "If the execution is not terminated")
+    public HttpResponse<?> setLabels(
+        @Parameter(description = "The execution id") @PathVariable String executionId,
+        @Parameter(description = "The labels to add to the execution") @Body @NotNull List<Label> labels
+    ) {
+        Optional<Execution> maybeExecution = executionRepository.findById(tenantService.resolveTenant(), executionId);
+        if (maybeExecution.isEmpty()) {
+            return HttpResponse.notFound();
+        }
+
+        Execution execution = maybeExecution.get();
+        if (!execution.getState().getCurrent().isTerminated()) {
+            return HttpResponse.badRequest("The execution is not terminated");
+        }
+
+        Execution newExecution = setLabels(execution, labels);
+        return HttpResponse.ok(newExecution);
+    }
+
+    private Execution setLabels(Execution execution, List<Label> labels) {
+        Map<String, String> newLabels = labels.stream().collect(Collectors.toMap(label -> label.key(), label -> label.value()));
+        if (execution.getLabels() != null) {
+            execution.getLabels().forEach(
+                label -> {
+                    // only add execution label if not updated
+                    if (!newLabels.containsKey(label.key())) {
+                        newLabels.put(label.key(), label.value());
+                    }
+                }
+            );
+        }
+
+        Execution newExecution = execution
+            .toBuilder()
+            .labels(newLabels.entrySet().stream().map(entry -> new Label(entry.getKey(), entry.getValue())).toList())
+            .build();
+        return executionRepository.save(newExecution);
+    }
+
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "/labels/by-ids")
+    @Operation(tags = {"Executions"}, summary = "Set labels on a list of executions")
+    @ApiResponse(responseCode = "200", description = "On success", content = {@Content(schema = @Schema(implementation = BulkResponse.class))})
+    @ApiResponse(responseCode = "422", description = "Killed with errors", content = {@Content(schema = @Schema(implementation = BulkErrorResponse.class))})
+    public MutableHttpResponse<?> setLabelsByIds(
+        @Parameter(description = "The request") @Body SetLabelsByIdsRequest setLabelsByIds
+    ) {
+        List<Execution> executions = new ArrayList<>();
+        Set<ManualConstraintViolation<String>> invalids = new HashSet<>();
+
+        for (String executionId : setLabelsByIds.executionsId()) {
+            Optional<Execution> execution = executionRepository.findById(tenantService.resolveTenant(), executionId);
+            if (execution.isPresent() && !execution.get().getState().isTerminated()) {
+                invalids.add(ManualConstraintViolation.of(
+                    "execution is not terminated",
+                    executionId,
+                    String.class,
+                    "execution",
+                    executionId
+                ));
+            } else if (execution.isEmpty()) {
+                invalids.add(ManualConstraintViolation.of(
+                    "execution not found",
+                    executionId,
+                    String.class,
+                    "execution",
+                    executionId
+                ));
+            } else {
+                executions.add(execution.get());
+            }
+        }
+
+        if (!invalids.isEmpty()) {
+            return HttpResponse.badRequest(BulkErrorResponse
+                .builder()
+                .message("invalid bulk set labels")
+                .invalids(invalids)
+                .build()
+            );
+        }
+
+        executions.forEach(execution -> setLabels(execution, setLabelsByIds.executionLabels()));
+        return HttpResponse.ok(BulkResponse.builder().count(executions.size()).build());
+    }
+
+    public record SetLabelsByIdsRequest(List<String> executionsId, List<Label> executionLabels) {}
+
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "/labels/by-query")
+    @Operation(tags = {"Executions"}, summary = "Set label on executions filter by query parameters")
+    public HttpResponse<?> setLabelsByQuery(
+        @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query,
+        @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace,
+        @Parameter(description = "A flow id filter") @Nullable @QueryValue String flowId,
+        @Parameter(description = "The start datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate,
+        @Parameter(description = "The end datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate,
+        @Parameter(description = "A time range filter relative to the current time", examples = {
+            @ExampleObject(name = "Filter last 5 minutes", value = "PT5M"),
+            @ExampleObject(name = "Filter last 24 hours", value = "P1D")
+        }) @Nullable @QueryValue Duration timeRange,
+        @Parameter(description = "A state filter") @Nullable @QueryValue List<State.Type> state,
+        @Parameter(description = "A labels filter as a list of 'key:value'") @Nullable @QueryValue List<String> labels,
+        @Parameter(description = "The trigger execution id") @Nullable @QueryValue String triggerExecutionId,
+        @Parameter(description = "A execution child filter") @Nullable @QueryValue ExecutionRepositoryInterface.ChildFilter childFilter,
+        @Parameter(description = "The labels to add to the execution") @Body @NotNull List<Label> setLabels
+    ) {
+        var ids = executionRepository
+            .find(
+                query,
+                tenantService.resolveTenant(),
+                namespace,
+                flowId,
+                resolveAbsoluteDateTime(startDate, timeRange, ZonedDateTime.now()),
+                endDate,
+                state,
+                RequestUtils.toMap(labels),
+                triggerExecutionId,
+                childFilter
+            )
+            .map(Execution::getId)
+            .collectList()
+            .block();
+
+        return setLabelsByIds(new SetLabelsByIdsRequest(ids, setLabels));
+    }
 }


Test patch:
diff --git a/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java b/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java
index e5baf85292..b201010fee 100644
--- a/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java
+++ b/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java
@@ -515,4 +515,19 @@ protected void executionsCount() throws InterruptedException {
         assertThat(result.stream().filter(executionCount -> executionCount.getFlowId().equals("second")).findFirst().get().getCount(), is(3L));
         assertThat(result.stream().filter(executionCount -> executionCount.getFlowId().equals("third")).findFirst().get().getCount(), is(9L));
     }
+
+    @Test
+    protected void update() {
+        Execution execution = ExecutionFixture.EXECUTION_1;
+        executionRepository.save(ExecutionFixture.EXECUTION_1);
+
+        Label label = new Label("key", "value");
+        Execution updated = execution.toBuilder().labels(List.of(label)).build();
+        executionRepository.update(updated);
+
+        Optional<Execution> validation = executionRepository.findById(null, updated.getId());
+        assertThat(validation.isPresent(), is(true));
+        assertThat(validation.get().getLabels().size(), is(1));
+        assertThat(validation.get().getLabels().get(0), is(label));
+    }
 }
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
index d606af5229..1b95b4d1b4 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
@@ -53,8 +53,7 @@
 import static io.kestra.core.utils.Rethrow.throwRunnable;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.*;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
 
 class ExecutionControllerTest extends JdbcH2ControllerTest {
     public static final String URL_LABEL_VALUE = "https://some-url.com";
@@ -809,4 +808,55 @@ void deleteByQuery() {
         );
         assertThat(response.getCount(), is(3));
     }
+
+    @Test
+    void setLabels() {
+        // update label on a terminated execution
+        Execution result = triggerInputsFlowExecution(true);
+        assertThat(result.getState().getCurrent(), is(State.Type.SUCCESS));
+        Execution response = client.toBlocking().retrieve(
+            HttpRequest.POST("/api/v1/executions/" + result.getId() + "/labels", List.of(new Label("key", "value"))),
+            Execution.class
+        );
+        assertThat(response.getLabels(), hasItem(new Label("key", "value")));
+
+        // update label on a not found execution
+        var exception = assertThrows(
+            HttpClientResponseException.class,
+            () -> client.toBlocking().exchange(HttpRequest.POST("/api/v1/executions/notfound/labels", List.of(new Label("key", "value"))))
+        );
+        assertThat(exception.getStatus(), is(HttpStatus.NOT_FOUND));
+    }
+
+    @Test
+    void setLabelsByIds() {
+        Execution result1 = triggerInputsFlowExecution(true);
+        Execution result2 = triggerInputsFlowExecution(true);
+        Execution result3 = triggerInputsFlowExecution(true);
+
+        BulkResponse response = client.toBlocking().retrieve(
+            HttpRequest.POST("/api/v1/executions/labels/by-ids",
+                new ExecutionController.SetLabelsByIdsRequest(List.of(result1.getId(), result2.getId(), result3.getId()), List.of(new Label("key", "value")))
+            ),
+            BulkResponse.class
+        );
+
+        assertThat(response.getCount(), is(3));
+    }
+
+    @Test
+    void setLabelsByQuery() {
+        Execution result1 = triggerInputsFlowExecution(true);
+        Execution result2 = triggerInputsFlowExecution(true);
+        Execution result3 = triggerInputsFlowExecution(true);
+
+        BulkResponse response = client.toBlocking().retrieve(
+            HttpRequest.POST("/api/v1/executions/labels/by-query?namespace=" + result1.getNamespace(),
+                List.of(new Label("key", "value"))
+            ),
+            BulkResponse.class
+        );
+
+        assertThat(response.getCount(), is(3));
+    }
 }