Repository: ros2/ros2cli
Pull request #1036: add QoS option to ros2service/ros2action echo commands.
Author: login: fujitatomoya
Created: 2025-05-23T05:04:27Z | Merged: 2025-05-29T16:12:41Z

## Description

Fixes #1023 

### Is this user-facing behavior change?

No, QoS arguments are optional, and default QoS configuration is consistent with before.

### Did you use Generative AI?

No

### Additional Information

N.A

Linked issue #1023: `ros2 service/action echo` should support QoS configuration option.
### Description

Service (from jazzy) and Action (from kilted) Introspection are supported and can be used by `ros2cli` such as `ros2 service echo <service name>` and `ros2 action echo <action name>` to monitor the service and action communication.
But it does not allow us to configure QoS configuration via command line interfaces.
This can be a problem if introspection is enabled by service and action endpoints with strict QoS configuration.
If this happens, `ros2 service echo` and `ros2 action echo` cannot subscribe the internal hidden topics or service events because QoS incompatibility.

Against this limitation, `ros2 service echo <service name>` and `ros2 action echo <action name>` should support QoS configuration options to adjust.

### Motivation

`ros2 service echo <service name>` and `ros2 action echo <action name>` user experience and flexibility to set QoS configuration via CLI. so that user can adjust the QoS configuration for service and action introspection via command line tools.

### Design / Implementation Considerations

QoS configuration argument has been integrated to many other (sub)commands. I think this can be straight-forward just to add QoS argument parser to take the QoS configuration from command line.

### Additional Information

_No response_

Diff:
diff --git a/ros2action/package.xml b/ros2action/package.xml
index 91f9cb60b..a8604572c 100644
--- a/ros2action/package.xml
+++ b/ros2action/package.xml
@@ -21,6 +21,7 @@
   <exec_depend>ament_index_python</exec_depend>
   <exec_depend>rclpy</exec_depend>
   <exec_depend>ros2cli</exec_depend>
+  <exec_depend>ros2topic</exec_depend>
   <exec_depend>rosidl_runtime_py</exec_depend>
 
   <test_depend>ament_copyright</test_depend>
diff --git a/ros2action/ros2action/verb/echo.py b/ros2action/ros2action/verb/echo.py
index 685169ac4..672ac0d4b 100644
--- a/ros2action/ros2action/verb/echo.py
+++ b/ros2action/ros2action/verb/echo.py
@@ -20,7 +20,7 @@
 
 import rclpy
 from rclpy.qos import qos_profile_action_status_default
-from rclpy.qos import qos_profile_services_default
+from rclpy.qos import QoSPresetProfiles
 from rclpy.qos import QoSProfile
 from rclpy.subscription import Subscription
 from rclpy.type_support import EventMessage
@@ -34,6 +34,8 @@
 from ros2cli.helpers import unsigned_int
 from ros2cli.node.strategy import NodeStrategy
 
+from ros2topic.api import add_qos_arguments, profile_configure_short_keys
+
 from rosidl_runtime_py import message_to_csv
 from rosidl_runtime_py import message_to_ordereddict
 from rosidl_runtime_py.utilities import get_action
@@ -107,6 +109,7 @@ def add_arguments(self, parser, cli_name):
         parser.add_argument(
             '--flow-style', action='store_true',
             help='Print collections in the block style (not available with csv format)')
+        add_qos_arguments(parser, 'action introspection', 'services_default')
 
     def main(self, *, args):
         output_interface_list = set()
@@ -147,6 +150,12 @@ def main(self, *, args):
         self.no_arr = args.no_arr
         self.no_str = args.no_str
 
+        qos_profile = QoSPresetProfiles.get_from_short_key(args.qos_profile)
+        profile_configure_short_keys(
+            qos_profile, args.qos_reliability, args.qos_durability,
+            args.qos_depth, args.qos_history, args.qos_liveliness,
+            args.qos_liveliness_lease_duration_seconds)
+
         send_goal_event_topic = args.action_name + '/_action/send_goal/_service_event'
         send_goal_event_msg_type = action_module.Impl.SendGoalService.Event
 
@@ -187,10 +196,10 @@ def message_handler():
             # specifying QoS of subscription to replace the current fixed QoS.
             if ActionInterfaces.GOAL_SERVICE.value in output_interface_list:
                 send_goal_event_sub: Subscription[EventMessage] = node.create_subscription(
-                        send_goal_event_msg_type,
-                        send_goal_event_topic,
-                        self._send_goal_subscriber_callback,
-                        qos_profile_services_default)
+                    send_goal_event_msg_type,
+                    send_goal_event_topic,
+                    self._send_goal_subscriber_callback,
+                    qos_profile)
 
             cancel_goal_event_sub = None
             if ActionInterfaces.CANCEL_SERVICE.value in output_interface_list:
@@ -198,7 +207,7 @@ def message_handler():
                     cancel_goal_event_msg_type,
                     cancel_goal_event_topic,
                     self._cancel_goal_subscriber_callback,
-                    qos_profile_services_default)
+                    qos_profile)
 
             get_result_event_sub = None
             if ActionInterfaces.RESULT_SERVICE.value in output_interface_list:
@@ -206,7 +215,7 @@ def message_handler():
                     get_result_event_msg_type,
                     get_result_event_topic,
                     self._get_result_subscriber_callback,
-                    qos_profile_services_default)
+                    qos_profile)
 
             feedback_sub = None
             if ActionInterfaces.FEEDBACK_TOPIC.value in output_interface_list:
diff --git a/ros2action/test/fixtures/fibonacci_action_introspection.py b/ros2action/test/fixtures/fibonacci_action_introspection.py
index 5698b66c3..9f4da248c 100644
--- a/ros2action/test/fixtures/fibonacci_action_introspection.py
+++ b/ros2action/test/fixtures/fibonacci_action_introspection.py
@@ -61,15 +61,26 @@ def __init__(self):
         self._action_client = ActionClient(self, Fibonacci, 'fibonacci')
         self._action_client.configure_introspection(
             self.get_clock(), qos_profile_system_default, ServiceIntrospectionState.CONTENTS)
-        self._timer = self.create_timer(2, self._timer_callback)
+        self._timer = self.create_timer(3, self._timer_callback)
+        self._send_goal_future = None
 
     def _timer_callback(self):
-        self._timer.cancel()
         if not self._action_client.wait_for_server():
-            self.get_logger().info('Action erver is unavailable.')
+            self.get_logger().info('Action server is unavailable.')
             return
 
-        self.send_goal(2)
+        if self._send_goal_future is None:
+            self.send_goal(2)
+            return
+
+        if not self._send_goal_future.done():
+            return
+
+        if self._send_goal_future.result() is None:
+            self.get_logger().error('Exception generated on the goal: {0}'.format(
+                self._send_goal_future.exception()))
+
+        self._send_goal_future = None
 
     def send_goal(self, order):
         goal_msg = Fibonacci.Goal()
diff --git a/ros2action/test/test_echo.py b/ros2action/test/test_echo.py
index 1b11ee110..b3cc6bb46 100644
--- a/ros2action/test/test_echo.py
+++ b/ros2action/test/test_echo.py
@@ -37,6 +37,112 @@
 from rclpy.utilities import get_available_rmw_implementations
 from ros2cli.helpers import get_rmw_additional_env
 
+EXPECTED_OUTPUT = [
+    [
+        'interface: GOAL_SERVICE',
+        'info:',
+        '  event_type: REQUEST_SENT',
+        '  stamp:',
+        re.compile(r'    sec: \d+'),
+        re.compile(r'    nanosec: \d+'),
+        "  client_gid: '<array type: uint8[16]>'",
+        re.compile(r'  sequence_number: \d+'),
+        "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
+        "length: 1>'",
+        "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
+        "length: 0>'",
+        '---',
+    ],
+    [
+        'interface: GOAL_SERVICE',
+        'info:',
+        '  event_type: REQUEST_RECEIVED',
+        '  stamp:',
+        re.compile(r'    sec: \d+'),
+        re.compile(r'    nanosec: \d+'),
+        "  client_gid: '<array type: uint8[16]>'",
+        re.compile(r'  sequence_number: \d+'),
+        "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
+        "length: 1>'",
+        "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
+        "length: 0>'",
+        '---',
+    ],
+    [
+        'interface: GOAL_SERVICE',
+        'info:',
+        '  event_type: RESPONSE_SENT',
+        '  stamp:',
+        re.compile(r'    sec: \d+'),
+        re.compile(r'    nanosec: \d+'),
+        "  client_gid: '<array type: uint8[16]>'",
+        re.compile(r'  sequence_number: \d+'),
+        "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
+        "length: 0>'",
+        "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
+        "length: 1>'",
+        '---',
+    ],
+    [
+        'interface: GOAL_SERVICE',
+        'info:',
+        '  event_type: RESPONSE_RECEIVED',
+        '  stamp:',
+        re.compile(r'    sec: \d+'),
+        re.compile(r'    nanosec: \d+'),
+        "  client_gid: '<array type: uint8[16]>'",
+        re.compile(r'  sequence_number: \d+'),
+        "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
+        "length: 0>'",
+        "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
+        "length: 1>'",
+        '---',
+    ],
+    [
+        'interface: STATUS_TOPIC',
+        "status_list: '<sequence type: action_msgs/msg/GoalStatus, length: 1>'",
+        '---',
+    ],
+    [
+        'interface: FEEDBACK_TOPIC',
+        'goal_id:',
+        "  uuid: '<array type: uint8[16]>'",
+        'feedback:',
+        "  sequence: '<sequence type: int32, length: 3>'",
+        '---',
+    ],
+    [
+        'interface: RESULT_SERVICE',
+        'info:',
+        '  event_type: REQUEST_RECEIVED',
+        '  stamp:',
+        re.compile(r'    sec: \d+'),
+        re.compile(r'    nanosec: \d+'),
+        "  client_gid: '<array type: uint8[16]>'",
+        re.compile(r'  sequence_number: \d+'),
+        "request: '<sequence type: test_msgs/action/Fibonacci_GetResult_Request[1], "
+        "length: 1>'",
+        "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], "
+        "length: 0>'",
+        '---',
+    ],
+    [
+        'interface: RESULT_SERVICE',
+        'info:',
+        '  event_type: RESPONSE_SENT',
+        '  stamp:',
+        re.compile(r'    sec: \d+'),
+        re.compile(r'    nanosec: \d+'),
+        "  client_gid: '<array type: uint8[16]>'",
+        re.compile(r'  sequence_number: \d+'),
+        "request: '<sequence type: test_msgs/action/Fibonacci_GetResult_Request[1], "
+        "length: 0>'",
+        "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], "
+        "length: 1>'",
+        '---'
+    ]
+]
+
 
 # Skip cli tests on Windows while they exhibit pathological behavior
 # https://github.com/ros2/build_farmer/issues/248
@@ -46,6 +152,68 @@
             allow_module_level=True)
 
 
+# Check if all parts of the expected_output are present in the output.
+def match_expected_output(output, expected_output) -> bool:
+    output_lines_tmp = output.splitlines()
+    output_lines = [remove_ansi_escape_sequences(line) for line in output_lines_tmp]
+
+    for msg_block in expected_output:
+
+        # Find the position where the first message of the message block appears.
+        matched_begin_lines = []
+        if isinstance(msg_block[0], str):
+            for index, line in enumerate(output_lines):
+                if msg_block[0] == line:
+                    matched_begin_lines.append(index)
+
+        if hasattr(msg_block[0], 'match'):
+            for index, line in enumerate(output_lines):
+                if msg_block[0].match(line):
+                    matched_begin_lines.append(index)
+
+        filtered_matched_begin_lines = matched_begin_lines
+        if matched_begin_lines:
+            for line in matched_begin_lines:
+                if len(msg_block) > len(output_lines) - line:
+                    filtered_matched_begin_lines.remove(line)
+
+            if not filtered_matched_begin_lines:
+                return False
+        else:
+            return False
+
+        # filter_matched_begin_lines include all positions where the first message appears.
+
+        msg_block_matched = False
+        for line in filtered_matched_begin_lines:
+            matched_index = -1
+            for index, (expected_line, output_line) in enumerate(
+                zip(msg_block, output_lines[line:]), start=1
+            ):
+                if isinstance(expected_line, str):
+                    if expected_line != output_line:
+                        break
+                    else:
+                        matched_index = index
+                        continue
+                if hasattr(expected_line, 'match'):
+                    if not expected_line.match(output_line):
+                        break
+                    else:
+                        matched_index = index
+                        continue
+            # A complete match has been found, skipping the rest of the detection.
+            if (matched_index == len(msg_block)):
+                msg_block_matched = True
+                break
+
+        # If no message block is matched throughout the entire output.
+        if not msg_block_matched:
+            return False
+
+    return True
+
+
 @pytest.mark.rostest
 @launch_testing.parametrize('rmw_implementation', get_available_rmw_implementations())
 def generate_test_description(rmw_implementation):
@@ -109,181 +277,28 @@ def test_echo(self):
         echo_arguments = [
             'echo', '/test/fibonacci', 'test_msgs/action/Fibonacci', '--no-arr']
 
-        # Define the desired output message block.
-        expected_output = [
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: REQUEST_SENT',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 1>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 0>'",
-                '---',
-            ],
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: REQUEST_RECEIVED',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 1>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 0>'",
-                '---',
-            ],
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: RESPONSE_SENT',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 0>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 1>'",
-                '---',
-            ],
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: RESPONSE_RECEIVED',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 0>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 1>'",
-                '---',
-            ],
-            [
-                'interface: STATUS_TOPIC',
-                "status_list: '<sequence type: action_msgs/msg/GoalStatus, length: 1>'",
-                '---',
-            ],
-            [
-                'interface: FEEDBACK_TOPIC',
-                'goal_id:',
-                "  uuid: '<array type: uint8[16]>'",
-                'feedback:',
-                "  sequence: '<sequence type: int32, length: 3>'",
-                '---',
-            ],
-            [
-                'interface: RESULT_SERVICE',
-                'info:',
-                '  event_type: REQUEST_RECEIVED',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_GetResult_Request[1], "
-                "length: 1>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], "
-                "length: 0>'",
-                '---',
-            ],
-            [
-                'interface: RESULT_SERVICE',
-                'info:',
-                '  event_type: RESPONSE_SENT',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_GetResult_Request[1], "
-                "length: 0>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], "
-                "length: 1>'",
-                '---'
-            ]
-        ]
-
-        # Check if all parts of the expected_output are present in the output.
-        def match_expected_output(output, expected_output) -> bool:
-            output_lines_tmp = output.splitlines()
-            output_lines = [remove_ansi_escape_sequences(line) for line in output_lines_tmp]
-
-            for msg_block in expected_output:
-
-                # Find the position where the first message of the message block appears.
-                matched_begin_lines = []
-                if isinstance(msg_block[0], str):
-                    for index, line in enumerate(output_lines):
-                        if msg_block[0] == line:
-                            matched_begin_lines.append(index)
-
-                if hasattr(msg_block[0], 'match'):
-                    for index, line in enumerate(output_lines):
-                        if msg_block[0].match(line):
-                            matched_begin_lines.append(index)
-
-                filtered_matched_begin_lines = matched_begin_lines
-                if matched_begin_lines:
-                    for line in matched_begin_lines:
-                        if len(msg_block) > len(output_lines) - line:
-                            filtered_matched_begin_lines.remove(line)
-
-                    if not filtered_matched_begin_lines:
-                        return False
-                else:
-                    return False
-
-                # filter_matched_begin_lines include all positions where the first message appears.
-
-                msg_block_matched = False
-                for line in filtered_matched_begin_lines:
-                    matched_index = -1
-                    for index, (expected_line, output_line) in enumerate(
-                        zip(msg_block, output_lines[line:]), start=1
-                    ):
-                        if isinstance(expected_line, str):
-                            if expected_line != output_line:
-                                break
-                            else:
-                                matched_index = index
-                                continue
-                        if hasattr(expected_line, 'match'):
-                            if not expected_line.match(output_line):
-                                break
-                            else:
-                                matched_index = index
-                                continue
-                    # A complete match has been found, skipping the rest of the detection.
-                    if (matched_index == len(msg_block)):
-                        msg_block_matched = True
-                        break
-
-                # If no message block is matched throughout the entire output.
-                if not msg_block_matched:
-                    return False
+        with self.launch_action_command(arguments=echo_arguments) as action_command:
+            assert action_command.wait_for_output(
+                functools.partial(
+                    match_expected_output,
+                    expected_output=EXPECTED_OUTPUT,
+                ),
+                timeout=10,
+            )
+        assert action_command.wait_for_shutdown(timeout=10)
 
-            return True
+    # @launch_testing.markers.retry_on_failure(times=5, delay=1)
+    def test_echo_with_qos(self):
+        echo_arguments = [
+            'echo', '/test/fibonacci', 'test_msgs/action/Fibonacci', '--no-arr',
+            '--qos-profile', 'services_default']
 
         with self.launch_action_command(arguments=echo_arguments) as action_command:
-            # assert action_command.wait_for_output(timeout=10)
             assert action_command.wait_for_output(
                 functools.partial(
                     match_expected_output,
-                    expected_output=expected_output,
+                    expected_output=EXPECTED_OUTPUT,
                 ),
-                timeout=5,
+                timeout=10,
             )
-        assert action_command.wait_for_shutdown(timeout=3)
+        assert action_command.wait_for_shutdown(timeout=10)
diff --git a/ros2service/ros2service/verb/echo.py b/ros2service/ros2service/verb/echo.py
index e484bd7a8..345e8b4b8 100644
--- a/ros2service/ros2service/verb/echo.py
+++ b/ros2service/ros2service/verb/echo.py
@@ -25,6 +25,7 @@
 from ros2service.api import ServiceNameCompleter
 from ros2service.api import ServiceTypeCompleter
 from ros2service.verb import VerbExtension
+from ros2topic.api import add_qos_arguments, profile_configure_short_keys
 from rosidl_runtime_py import message_to_csv
 from rosidl_runtime_py import message_to_ordereddict
 from rosidl_runtime_py.utilities import get_service
@@ -86,6 +87,7 @@ def add_arguments(self, parser, cli_name):
         parser.add_argument(
             '--flow-style', action='store_true',
             help='Print collections in the block style (not available with csv format)')
+        add_qos_arguments(parser, 'service introspection', 'services_default')
 
     def main(self, *, args):
         if args.service_type is None:
@@ -115,12 +117,18 @@ def main(self, *, args):
         self.no_arr = args.no_arr
         self.no_str = args.no_str
 
+        qos_profile = QoSPresetProfiles.get_from_short_key(args.qos_profile)
+        profile_configure_short_keys(
+            qos_profile, args.qos_reliability, args.qos_durability,
+            args.qos_depth, args.qos_history, args.qos_liveliness,
+            args.qos_liveliness_lease_duration_seconds)
+
         with NodeStrategy(args) as node:
             sub = node.create_subscription(
                 event_msg_type,
                 event_topic_name,
                 self._subscriber_callback,
-                QoSPresetProfiles.get_from_short_key('services_default'))
+                qos_profile=qos_profile)
 
             have_printed_warning = False
             executor = rclpy.get_global_executor()
diff --git a/ros2service/test/test_echo.py b/ros2service/test/test_echo.py
index bb6924f38..b78d1cc6e 100644
--- a/ros2service/test/test_echo.py
+++ b/ros2service/test/test_echo.py
@@ -35,6 +35,48 @@
 from rclpy.utilities import get_available_rmw_implementations
 from ros2cli.helpers import get_rmw_additional_env
 
+EXPECTED_OUTPUT = [
+    'info:',
+    '  event_type: REQUEST_SENT',
+    '  stamp:',
+    re.compile(r'    sec: \d+'),
+    re.compile(r'    nanosec: \d+'),
+    "  client_gid: '<array type: uint8[16]>'",
+    re.compile(r'  sequence_number: \d+'),
+    "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 1>'",
+    "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 0>'",
+    '---',
+    'info:',
+    '  event_type: REQUEST_RECEIVED',
+    '  stamp:',
+    re.compile(r'    sec: \d+'),
+    re.compile(r'    nanosec: \d+'),
+    "  client_gid: '<array type: uint8[16]>'",
+    re.compile(r'  sequence_number: \d+'),
+    "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 1>'",
+    "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 0>'",
+    '---',
+    'info:',
+    '  event_type: RESPONSE_SENT',
+    '  stamp:',
+    re.compile(r'    sec: \d+'),
+    re.compile(r'    nanosec: \d+'),
+    "  client_gid: '<array type: uint8[16]>'",
+    re.compile(r'  sequence_number: \d+'),
+    "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 0>'",
+    "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 1>'",
+    '---',
+    'info:',
+    '  event_type: RESPONSE_RECEIVED',
+    '  stamp:',
+    re.compile(r'    sec: \d+'),
+    re.compile(r'    nanosec: \d+'),
+    "  client_gid: '<array type: uint8[16]>'",
+    re.compile(r'  sequence_number: \d+'),
+    "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 0>'",
+    "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 1>'",
+]
+
 
 # Skip cli tests on Windows while they exhibit pathological behavior
 # https://github.com/ros2/build_farmer/issues/248
@@ -113,53 +155,28 @@ def launch_service_command(self, arguments):
     @launch_testing.markers.retry_on_failure(times=5, delay=1)
     def test_echo_no_arr(self):
         echo_arguments = ['echo', '/test_introspectable', '--no-arr']
-        expected_output = [
-            'info:',
-            '  event_type: REQUEST_SENT',
-            '  stamp:',
-            re.compile(r'    sec: \d+'),
-            re.compile(r'    nanosec: \d+'),
-            "  client_gid: '<array type: uint8[16]>'",
-            re.compile(r'  sequence_number: \d+'),
-            "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 1>'",
-            "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 0>'",
-            '---',
-            'info:',
-            '  event_type: REQUEST_RECEIVED',
-            '  stamp:',
-            re.compile(r'    sec: \d+'),
-            re.compile(r'    nanosec: \d+'),
-            "  client_gid: '<array type: uint8[16]>'",
-            re.compile(r'  sequence_number: \d+'),
-            "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 1>'",
-            "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 0>'",
-            '---',
-            'info:',
-            '  event_type: RESPONSE_SENT',
-            '  stamp:',
-            re.compile(r'    sec: \d+'),
-            re.compile(r'    nanosec: \d+'),
-            "  client_gid: '<array type: uint8[16]>'",
-            re.compile(r'  sequence_number: \d+'),
-            "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 0>'",
-            "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 1>'",
-            '---',
-            'info:',
-            '  event_type: RESPONSE_RECEIVED',
-            '  stamp:',
-            re.compile(r'    sec: \d+'),
-            re.compile(r'    nanosec: \d+'),
-            "  client_gid: '<array type: uint8[16]>'",
-            re.compile(r'  sequence_number: \d+'),
-            "request: '<sequence type: test_msgs/srv/BasicTypes_Request[1], length: 0>'",
-            "response: '<sequence type: test_msgs/srv/BasicTypes_Response[1], length: 1>'",
-        ],
 
         with self.launch_service_command(arguments=echo_arguments) as service_command:
             assert service_command.wait_for_output(
                 functools.partial(
                     launch_testing.tools.expect_output,
-                    expected_lines=expected_output,
+                    expected_lines=EXPECTED_OUTPUT,
+                    strict=True
+                ),
+                timeout=10,
+            )
+        assert service_command.wait_for_shutdown(timeout=10)
+
+    @launch_testing.markers.retry_on_failure(times=5, delay=1)
+    def test_echo_no_arr_with_qos(self):
+        echo_arguments = [
+            'echo', '/test_introspectable', '--no-arr', '--qos-profile', 'services_default']
+
+        with self.launch_service_command(arguments=echo_arguments) as service_command:
+            assert service_command.wait_for_output(
+                functools.partial(
+                    launch_testing.tools.expect_output,
+                    expected_lines=EXPECTED_OUTPUT,
                     strict=True
                 ),
                 timeout=10,


Reviews:
user: login: fujitatomoya
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T05:11:12Z
user: login: Barry-Xu-2018
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T04:49:10Z
user: login: fujitatomoya
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T19:30:03Z
user: login: fujitatomoya
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T19:35:15Z
user: login: Barry-Xu-2018
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T01:02:04Z

Review comments:
diff_hunk: @@ -107,6 +109,7 @@ def add_arguments(self, parser, cli_name):
         parser.add_argument(
             '--flow-style', action='store_true',
             help='Print collections in the block style (not available with csv format)')
+        add_qos_arguments(parser, 'action introspection', 'services_default')
path: ros2action/ros2action/verb/echo.py
user: login: fujitatomoya
body: this keeps the default QoS configuration consistent with before.
created_at: 2025-05-23T05:05:10Z
author_association: COLLABORATOR
diff_hunk: @@ -61,15 +61,26 @@ def __init__(self):
         self._action_client = ActionClient(self, Fibonacci, 'fibonacci')
         self._action_client.configure_introspection(
             self.get_clock(), qos_profile_system_default, ServiceIntrospectionState.CONTENTS)
-        self._timer = self.create_timer(2, self._timer_callback)
+        self._timer = self.create_timer(3, self._timer_callback)
+        self._send_goal_future = None
 
     def _timer_callback(self):
-        self._timer.cancel()
         if not self._action_client.wait_for_server():
-            self.get_logger().info('Action erver is unavailable.')
+            self.get_logger().info('Action server is unavailable.')
             return
 
-        self.send_goal(2)
+        if self._send_goal_future is None:
+            self.send_goal(2)
+            return
+
+        if not self._send_goal_future.done():
+            return
+
+        if self._send_goal_future.result() is None:
+            self.get_logger().error('Exception generated on the goal: {0}'.format(
+                self._send_goal_future.exception()))
+
+        self._send_goal_future = None
path: ros2action/test/fixtures/fibonacci_action_introspection.py
user: login: fujitatomoya
body: this changes the test fixture behavior a bit. before this is just a one shot timer to start the action communication, now it tries to call the action request if the previous goal is completed. this makes the test process more robust, and it can check the expected output messages are in the command line output with given test time.
created_at: 2025-05-23T05:07:15Z
author_association: COLLABORATOR
diff_hunk: @@ -109,181 +277,28 @@ def test_echo(self):
         echo_arguments = [
             'echo', '/test/fibonacci', 'test_msgs/action/Fibonacci', '--no-arr']
 
-        # Define the desired output message block.
-        expected_output = [
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: REQUEST_SENT',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 1>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 0>'",
-                '---',
-            ],
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: REQUEST_RECEIVED',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 1>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 0>'",
-                '---',
-            ],
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: RESPONSE_SENT',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 0>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 1>'",
-                '---',
-            ],
-            [
-                'interface: GOAL_SERVICE',
-                'info:',
-                '  event_type: RESPONSE_RECEIVED',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Request[1], "
-                "length: 0>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_SendGoal_Response[1], "
-                "length: 1>'",
-                '---',
-            ],
-            [
-                'interface: STATUS_TOPIC',
-                "status_list: '<sequence type: action_msgs/msg/GoalStatus, length: 1>'",
-                '---',
-            ],
-            [
-                'interface: FEEDBACK_TOPIC',
-                'goal_id:',
-                "  uuid: '<array type: uint8[16]>'",
-                'feedback:',
-                "  sequence: '<sequence type: int32, length: 3>'",
-                '---',
-            ],
-            [
-                'interface: RESULT_SERVICE',
-                'info:',
-                '  event_type: REQUEST_RECEIVED',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_GetResult_Request[1], "
-                "length: 1>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], "
-                "length: 0>'",
-                '---',
-            ],
-            [
-                'interface: RESULT_SERVICE',
-                'info:',
-                '  event_type: RESPONSE_SENT',
-                '  stamp:',
-                re.compile(r'    sec: \d+'),
-                re.compile(r'    nanosec: \d+'),
-                "  client_gid: '<array type: uint8[16]>'",
-                re.compile(r'  sequence_number: \d+'),
-                "request: '<sequence type: test_msgs/action/Fibonacci_GetResult_Request[1], "
-                "length: 0>'",
-                "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], "
-                "length: 1>'",
-                '---'
-            ]
-        ]
-
-        # Check if all parts of the expected_output are present in the output.
-        def match_expected_output(output, expected_output) -> bool:
-            output_lines_tmp = output.splitlines()
-            output_lines = [remove_ansi_escape_sequences(line) for line in output_lines_tmp]
-
-            for msg_block in expected_output:
-
-                # Find the position where the first message of the message block appears.
-                matched_begin_lines = []
-                if isinstance(msg_block[0], str):
-                    for index, line in enumerate(output_lines):
-                        if msg_block[0] == line:
-                            matched_begin_lines.append(index)
-
-                if hasattr(msg_block[0], 'match'):
-                    for index, line in enumerate(output_lines):
-                        if msg_block[0].match(line):
-                            matched_begin_lines.append(index)
-
-                filtered_matched_begin_lines = matched_begin_lines
-                if matched_begin_lines:
-                    for line in matched_begin_lines:
-                        if len(msg_block) > len(output_lines) - line:
-                            filtered_matched_begin_lines.remove(line)
-
-                    if not filtered_matched_begin_lines:
-                        return False
-                else:
-                    return False
-
-                # filter_matched_begin_lines include all positions where the first message appears.
-
-                msg_block_matched = False
-                for line in filtered_matched_begin_lines:
-                    matched_index = -1
-                    for index, (expected_line, output_line) in enumerate(
-                        zip(msg_block, output_lines[line:]), start=1
-                    ):
-                        if isinstance(expected_line, str):
-                            if expected_line != output_line:
-                                break
-                            else:
-                                matched_index = index
-                                continue
-                        if hasattr(expected_line, 'match'):
-                            if not expected_line.match(output_line):
-                                break
-                            else:
-                                matched_index = index
-                                continue
-                    # A complete match has been found, skipping the rest of the detection.
-                    if (matched_index == len(msg_block)):
-                        msg_block_matched = True
-                        break
-
-                # If no message block is matched throughout the entire output.
-                if not msg_block_matched:
-                    return False
+        with self.launch_action_command(arguments=echo_arguments) as action_command:
+            assert action_command.wait_for_output(
+                functools.partial(
+                    match_expected_output,
+                    expected_output=EXPECTED_OUTPUT,
+                ),
+                timeout=10,
+            )
+        assert action_command.wait_for_shutdown(timeout=10)
path: ros2action/test/test_echo.py
user: login: fujitatomoya
body: I needed to adjust these a bit. because that is not aligned with comment, and zenoh requires longer wait timeout, otherwise it fails here.
created_at: 2025-05-23T05:08:34Z
author_association: COLLABORATOR
diff_hunk: @@ -86,6 +87,7 @@ def add_arguments(self, parser, cli_name):
         parser.add_argument(
             '--flow-style', action='store_true',
             help='Print collections in the block style (not available with csv format)')
+        add_qos_arguments(parser, 'service introspection', 'services_default')
path: ros2service/ros2service/verb/echo.py
user: login: fujitatomoya
body: this also keeps the same QoS configuration as before.
created_at: 2025-05-23T05:09:08Z
author_association: COLLABORATOR
diff_hunk: @@ -187,26 +195,26 @@ def message_handler():
             # specifying QoS of subscription to replace the current fixed QoS.
             if ActionInterfaces.GOAL_SERVICE.value in output_interface_list:
                 send_goal_event_sub: Subscription[EventMessage] = node.create_subscription(
-                        send_goal_event_msg_type,
-                        send_goal_event_topic,
-                        self._send_goal_subscriber_callback,
-                        qos_profile_services_default)
+                    send_goal_event_msg_type,
+                    send_goal_event_topic,
+                    self._send_goal_subscriber_callback,
+                    qos_profile)
path: ros2action/ros2action/verb/echo.py
user: login: fujitatomoya
body: Note that QoS configuration from command line arguments are only applied to `services`, since status and feedback topics have integrated QoS configuration that user cannot change. if we apply the user specified QoS configuration for the topics status and feedback, it can fail to create the subscription because of the QoS incompatibility.
created_at: 2025-05-23T05:11:01Z
author_association: COLLABORATOR
diff_hunk: @@ -147,6 +150,11 @@ def main(self, *, args):
         self.no_arr = args.no_arr
         self.no_str = args.no_str
 
+        qos_profile = QoSPresetProfiles.get_from_short_key(args.qos_profile)
+        profile_configure_short_keys(
+            qos_profile, args.qos_reliability, args.qos_durability,
+            args.qos_depth, args.qos_history)
path: ros2action/ros2action/verb/echo.py
user: login: Barry-Xu-2018
body: In the `add_qos_arguments()` added above, the parameters provided to the user include `--qos-liveliness` and `--qos-liveliness-lease-duration-seconds`.   So the user can modify these 2 parameters.  These two parameters should also be passed to `profile_configure_short_keys()` ?
created_at: 2025-05-26T04:48:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -115,12 +117,17 @@ def main(self, *, args):
         self.no_arr = args.no_arr
         self.no_str = args.no_str
 
+        qos_profile = QoSPresetProfiles.get_from_short_key(args.qos_profile)
+        profile_configure_short_keys(
+            qos_profile, args.qos_reliability, args.qos_durability,
+            args.qos_depth, args.qos_history)
path: ros2service/ros2service/verb/echo.py
user: login: Barry-Xu-2018
body: Like action, should parameters like `--qos-liveliness` and `--qos-liveliness-lease-duration-seconds` be passed in by the user?
created_at: 2025-05-26T04:48:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -147,6 +150,11 @@ def main(self, *, args):
         self.no_arr = args.no_arr
         self.no_str = args.no_str
 
+        qos_profile = QoSPresetProfiles.get_from_short_key(args.qos_profile)
+        profile_configure_short_keys(
+            qos_profile, args.qos_reliability, args.qos_durability,
+            args.qos_depth, args.qos_history)
path: ros2action/ros2action/verb/echo.py
user: login: fujitatomoya
body: you are absolutely right about this. we should pass all the optional parameters to configure the QoS. (actually i missed this because some other places do miss passing those parameters... i think this is the bug for some places, i will patch them with another PR.)
created_at: 2025-05-27T19:30:02Z
author_association: COLLABORATOR
diff_hunk: @@ -147,6 +150,11 @@ def main(self, *, args):
         self.no_arr = args.no_arr
         self.no_str = args.no_str
 
+        qos_profile = QoSPresetProfiles.get_from_short_key(args.qos_profile)
+        profile_configure_short_keys(
+            qos_profile, args.qos_reliability, args.qos_durability,
+            args.qos_depth, args.qos_history)
path: ros2action/ros2action/verb/echo.py
user: login: fujitatomoya
body: fixed with https://github.com/ros2/ros2cli/pull/1036/commits/aa637dea8de8d7b7f8a3711aee607aa2f2717e5b
created_at: 2025-05-27T19:35:15Z
author_association: COLLABORATOR

PR comments:
user: login: fujitatomoya
created_at: 2025-05-23T05:11:55Z
body: @Barry-Xu-2018 can you review this?

with my local environment, `ros2service` and `ros2action` tests pass without any error.
author_association: COLLABORATOR
user: login: fujitatomoya
created_at: 2025-05-27T19:36:36Z
body: @Barry-Xu-2018 requesting review again.
author_association: COLLABORATOR
user: login: fujitatomoya
created_at: 2025-05-27T19:37:49Z
body: Pulls: ros2/ros2cli#1036
Gist: https://gist.githubusercontent.com/fujitatomoya/26dbd12fd0b0b461b46f9e9865916635/raw/e4ac30a724ff31b23a2339a58d23d7b9336eb61c/ros2.repos
BUILD args:  --packages-above-and-dependencies ros2service ros2action
TEST args:  --packages-above ros2service ros2action
ROS Distro: rolling
Job: ci_launcher
ci_launcher ran: https://ci.ros2.org/job/ci_launcher/16085
* Linux [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_linux&build=23760)](http://ci.ros2.org/job/ci_linux/23760/)
* Linux-aarch64 [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_linux-aarch64&build=17855)](http://ci.ros2.org/job/ci_linux-aarch64/17855/)
* Linux-rhel [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_linux-rhel&build=3195)](http://ci.ros2.org/job/ci_linux-rhel/3195/)
* Windows [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_windows&build=24455)](http://ci.ros2.org/job/ci_windows/24455/)
author_association: COLLABORATOR
user: login: fujitatomoya
created_at: 2025-05-28T05:48:58Z
body: @Barry-Xu-2018 i do not quite understand why these CI failures (except windows 😓 ) are happening, do you have any thoughts?

```console
Traceback (most recent call last):
  File "/home/jenkins-agent/workspace/ci_linux-aarch64/ws/src/ros2/ros2cli/ros2action/test/test_echo.py", line 281, in test_echo
    assert action_command.wait_for_output(
AssertionError: assert <BoolWithText(False): 'Failed to load entry point \'echo\': No module named \'ros2topic\'\nTraceback (most recent call...s_on_demand\n    extension = command_extensions[name]\n                ~~~~~~~~~~~~~~~~~~^^^^^^\nKeyError: \'echo\'\n'>
 +  where <BoolWithText(False): 'Failed to load entry point \'echo\': No module named \'ros2topic\'\nTraceback (most recent call...s_on_demand\n    extension = command_extensions[name]\n                ~~~~~~~~~~~~~~~~~~^^^^^^\nKeyError: \'echo\'\n'> = <bound method ProcessProxy.wait_for_output of <launch_testing.tools.process.ProcessProxy object at 0xea9cc9371f40>>(functools.partial(<function match_expected_output at 0xea9cd1286520>, expected_output=[['interface: GOAL_SERVICE', 'in...[1], length: 0>'", "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], length: 1>'", '---']]), timeout=10)
 +    where <bound method ProcessProxy.wait_for_output of <launch_testing.tools.process.ProcessProxy object at 0xea9cc9371f40>> = <launch_testing.tools.process.ProcessProxy object at 0xea9cc9371f40>.wait_for_output
 +    and   functools.partial(<function match_expected_output at 0xea9cd1286520>, expected_output=[['interface: GOAL_SERVICE', 'in...[1], length: 0>'", "response: '<sequence type: test_msgs/action/Fibonacci_GetResult_Response[1], length: 1>'", '---']]) = <class 'functools.partial'>(match_expected_output, expected_output=[['interface: GOAL_SERVICE', 'info:', '  event_type: REQUEST_SENT', '  stamp:', re.compile('    sec: \\d+'), re.compil...id:', "  uuid: '<array type: uint8[16]>'", 'feedback:', "  sequence: '<sequence type: int32, length: 3>'", '---'], ...])
 +      where <class 'functools.partial'> = functools.partial
```
author_association: COLLABORATOR
user: login: Barry-Xu-2018
created_at: 2025-05-28T08:00:35Z
body: @fujitatomoya 

It is related to using `from ros2topic.api import add_qos_arguments, profile_configure_short_keys`.
So `ros2action` depended on `ros2topic`.   
I try to add `  <exec_depend>ros2topic</exec_depend>` to ros2cli/ros2action/package.xml. And tests can pass in my local environment.  
But this modification might not be an appropriate correction.  

`add_qos_arguments` and `profile_configure_short_keys` should be moved to `ros2cli` as common functions for other packages.  

author_association: CONTRIBUTOR
user: login: fujitatomoya
created_at: 2025-05-28T15:35:44Z
body: > I try to add   <exec_depend>ros2topic</exec_depend> to ros2cli/ros2action/package.xml. 

👍 i will add this.

> add_qos_arguments and profile_configure_short_keys should be moved to ros2cli as common functions for other packages.

this is refactoring, for the backport compatibility, i will address this with another PR.
author_association: COLLABORATOR
user: login: fujitatomoya
created_at: 2025-05-28T15:39:03Z
body: Pulls: ros2/ros2cli#1036
Gist: https://gist.githubusercontent.com/fujitatomoya/5bb06561eb6e3bf669c0a33fb07e202b/raw/e4ac30a724ff31b23a2339a58d23d7b9336eb61c/ros2.repos
BUILD args:  --packages-above-and-dependencies ros2action ros2service
TEST args:  --packages-above ros2action ros2service
ROS Distro: rolling
Job: ci_launcher
ci_launcher ran: https://ci.ros2.org/job/ci_launcher/16090
* Linux [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_linux&build=23779)](http://ci.ros2.org/job/ci_linux/23779/)
* Linux-aarch64 [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_linux-aarch64&build=17866)](http://ci.ros2.org/job/ci_linux-aarch64/17866/)
* Linux-rhel [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_linux-rhel&build=3202)](http://ci.ros2.org/job/ci_linux-rhel/3202/)
* Windows [![Build Status](http://ci.ros2.org/buildStatus/icon?job=ci_windows&build=24460)](http://ci.ros2.org/job/ci_windows/24460/)
author_association: COLLABORATOR
user: login: fujitatomoya
created_at: 2025-05-29T16:12:34Z
body: https://ci.ros2.org/job/ci_linux-aarch64/17866/testReport/ is unrelated.
author_association: COLLABORATOR
user: login: fujitatomoya
created_at: 2025-05-29T16:13:05Z
body: @Mergifyio backport kilted
author_association: COLLABORATOR
user: login: mergify[bot]
created_at: 2025-05-29T16:13:21Z
body: > backport kilted

#### ✅ Backports have been created

<details>

* [#1038 add QoS option to ros2service/ros2action echo commands. (backport #1036)](https://github.com/ros2/ros2cli/pull/1038) has been created for branch `kilted`

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "backport kilted", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: NONE