From d76811a47688e9f0820e59db2df0a84d41172c67 Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Mon, 9 Mar 2026 13:26:53 +0800 Subject: [PATCH 1/4] TRCLI-236: Fixed an issue where empty case validation is not skipped when adding empty runs via add_run command --- trcli/api/run_handler.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/trcli/api/run_handler.py b/trcli/api/run_handler.py index 94478ce..dd6f6cb 100644 --- a/trcli/api/run_handler.py +++ b/trcli/api/run_handler.py @@ -89,8 +89,14 @@ def add_run( ) # Validate that we have test cases to include in the run - # Empty runs are not allowed unless include_all is True - if not include_all and (not add_run_data.get("case_ids") or len(add_run_data["case_ids"]) == 0): + # Empty runs are not allowed for parse commands unless include_all is True + # However, add_run command explicitly allows empty runs for later result uploads + is_add_run_command = self.environment.cmd == "add_run" + if ( + not is_add_run_command + and not include_all + and (not add_run_data.get("case_ids") or len(add_run_data["case_ids"]) == 0) + ): error_msg = ( "Cannot create test run: No test cases were matched.\n" " - For parse_junit: Ensure tests have automation_id/test ids that matches existing cases in TestRail\n" From 2a7fa403ee60a7378caab216b76d63f4cd06728e Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 17 Mar 2026 15:19:46 +0800 Subject: [PATCH 2/4] TRCLI-239: Updated add_run command with user assignment commands --- trcli/api/api_request_handler.py | 5 +- trcli/api/project_based_client.py | 10 +++ trcli/api/run_handler.py | 11 +++- trcli/commands/cmd_add_run.py | 104 +++++++++++++++++------------- 4 files changed, 81 insertions(+), 49 deletions(-) diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index e1337b8..f0ee817 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -256,8 +256,11 @@ def update_run( milestone_id: int = None, refs: str = None, refs_action: str = "add", + assigned_to_id: Union[int, None] = ..., ) -> Tuple[dict, str]: - return self.run_handler.update_run(run_id, run_name, start_date, end_date, milestone_id, refs, refs_action) + return self.run_handler.update_run( + run_id, run_name, start_date, end_date, milestone_id, refs, refs_action, assigned_to_id + ) def _manage_references(self, existing_refs: str, new_refs: str, action: str) -> str: return self.run_handler._manage_references(existing_refs, new_refs, action) diff --git a/trcli/api/project_based_client.py b/trcli/api/project_based_client.py index fbfdfbc..55fbabe 100644 --- a/trcli/api/project_based_client.py +++ b/trcli/api/project_based_client.py @@ -213,6 +213,15 @@ def create_or_update_test_run(self) -> Tuple[int, str]: else: self.environment.log(f"Updating test run. ", new_line=False) run_id = self.environment.run_id + + # Determine assigned_to_id value based on flags + if hasattr(self.environment, "clear_run_assigned_to_id") and self.environment.clear_run_assigned_to_id: + assigned_to_id = None # Clear the assignee + elif self.environment.run_assigned_to_id: + assigned_to_id = self.environment.run_assigned_to_id # Set new assignee + else: + assigned_to_id = ... # Don't change (sentinel value) + run, error_message = self.api_request_handler.update_run( run_id, self.run_name, @@ -221,6 +230,7 @@ def create_or_update_test_run(self) -> Tuple[int, str]: milestone_id=self.environment.milestone_id, refs=self.environment.run_refs, refs_action=getattr(self.environment, "run_refs_action", "add"), + assigned_to_id=assigned_to_id, ) if self.environment.auto_close_run: self.environment.log("Closing run. ", new_line=False) diff --git a/trcli/api/run_handler.py b/trcli/api/run_handler.py index dd6f6cb..d30e972 100644 --- a/trcli/api/run_handler.py +++ b/trcli/api/run_handler.py @@ -8,7 +8,7 @@ - Closing and deleting runs """ -from beartype.typing import List, Tuple, Dict +from beartype.typing import List, Tuple, Dict, Union from trcli.api.api_client import APIClient from trcli.api.api_utils import ( @@ -135,6 +135,7 @@ def update_run( milestone_id: int = None, refs: str = None, refs_action: str = "add", + assigned_to_id: Union[int, None] = ..., ) -> Tuple[dict, str]: """ Updates an existing run @@ -142,10 +143,11 @@ def update_run( :param run_id: run id :param run_name: run name :param start_date: start date - :param end_date: end date + :param end_date: end_date: end date :param milestone_id: milestone id :param refs: references to manage :param refs_action: action to perform ('add', 'update', 'delete') + :param assigned_to_id: user ID to assign (int), None to clear, or ... to leave unchanged :returns: Tuple with run and error string. """ run_response = self.client.send_get(f"get_run/{run_id}") @@ -167,6 +169,11 @@ def update_run( else: add_run_data["refs"] = existing_refs # Keep existing refs if none provided + # Handle assigned_to_id - only add to payload if explicitly provided + if assigned_to_id is not ...: + add_run_data["assignedto_id"] = assigned_to_id # Can be None (clears) or int (sets) + # else: Don't include assignedto_id in payload (no change to existing assignee) + existing_include_all = run_response.response_text.get("include_all", False) add_run_data["include_all"] = existing_include_all diff --git a/trcli/commands/cmd_add_run.py b/trcli/commands/cmd_add_run.py index 7e84073..cb6ac15 100644 --- a/trcli/commands/cmd_add_run.py +++ b/trcli/commands/cmd_add_run.py @@ -7,20 +7,22 @@ def print_config(env: Environment): - env.log(f"Parser Results Execution Parameters" - f"\n> TestRail instance: {env.host} (user: {env.username})" - f"\n> Project: {env.project if env.project else env.project_id}" - f"\n> Run title: {env.title}" - f"\n> Suite ID: {env.suite_id}" - f"\n> Description: {env.run_description}" - f"\n> Milestone ID: {env.milestone_id}" - f"\n> Start Date: {env.run_start_date}" - f"\n> End Date: {env.run_end_date}" - f"\n> Assigned To ID: {env.run_assigned_to_id}" - f"\n> Include All: {env.run_include_all}" - f"\n> Case IDs: {env.run_case_ids}" - f"\n> Refs: {env.run_refs}" - f"\n> Refs Action: {env.run_refs_action if hasattr(env, 'run_refs_action') else 'add'}") + env.log( + f"Parser Results Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + f"\n> Run title: {env.title}" + f"\n> Suite ID: {env.suite_id}" + f"\n> Description: {env.run_description}" + f"\n> Milestone ID: {env.milestone_id}" + f"\n> Start Date: {env.run_start_date}" + f"\n> End Date: {env.run_end_date}" + f"\n> Assigned To ID: {env.run_assigned_to_id}" + f"\n> Include All: {env.run_include_all}" + f"\n> Case IDs: {env.run_case_ids}" + f"\n> Refs: {env.run_refs}" + f"\n> Refs Action: {env.run_refs_action if hasattr(env, 'run_refs_action') else 'add'}" + ) def write_run_to_file(environment: Environment, run_id: int): @@ -28,15 +30,15 @@ def write_run_to_file(environment: Environment, run_id: int): environment.log(f"Writing test run data to file ({environment.file}). ", new_line=False) data = dict(title=environment.title, run_id=run_id) if environment.run_description: - data['run_description'] = environment.run_description + data["run_description"] = environment.run_description if environment.run_refs: - data['run_refs'] = environment.run_refs + data["run_refs"] = environment.run_refs if environment.run_include_all: - data['run_include_all'] = environment.run_include_all + data["run_include_all"] = environment.run_include_all if environment.run_case_ids: - data['run_case_ids'] = environment.run_case_ids + data["run_case_ids"] = environment.run_case_ids if environment.run_assigned_to_id: - data['run_assigned_to_id'] = environment.run_assigned_to_id + data["run_assigned_to_id"] = environment.run_assigned_to_id with open(environment.file, "a") as f: f.write(yaml.dump(data, default_flow_style=False)) environment.log("Done.") @@ -67,51 +69,47 @@ def write_run_to_file(environment: Environment, run_id: int): "--run-start-date", metavar="", default=None, - type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], - help="The expected or scheduled start date of this test run in MM/DD/YYYY format" + type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], + help="The expected or scheduled start date of this test run in MM/DD/YYYY format", ) @click.option( "--run-end-date", metavar="", default=None, - type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], - help="The expected or scheduled end date of this test run in MM/DD/YYYY format" + type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], + help="The expected or scheduled end date of this test run in MM/DD/YYYY format", ) @click.option( "--run-assigned-to-id", type=click.IntRange(min=1), metavar="", - help="The ID of the user the test run should be assigned to." + help="The ID of the user the test run should be assigned to.", ) @click.option( - "--run-include-all", + "--clear-run-assigned-to-id", is_flag=True, default=False, - help="Use this option to include all test cases in this test run." + help="Clear the assignee of the test run (only valid when updating with --run-id).", ) @click.option( - "--auto-close-run", - is_flag=True, - default=False, - help="Use this option to automatically close the created run." + "--run-include-all", is_flag=True, default=False, help="Use this option to include all test cases in this test run." ) @click.option( - "--run-case-ids", - metavar="", - type=lambda x: [int(i) for i in x.split(",")], - help="Comma separated list of test case IDs to include in the test run (i.e.: 1,2,3,4)." + "--auto-close-run", is_flag=True, default=False, help="Use this option to automatically close the created run." ) @click.option( - "--run-refs", + "--run-case-ids", metavar="", - help="A comma-separated list of references/requirements (up to 250 characters)" + type=lambda x: [int(i) for i in x.split(",")], + help="Comma separated list of test case IDs to include in the test run (i.e.: 1,2,3,4).", ) +@click.option("--run-refs", metavar="", help="A comma-separated list of references/requirements (up to 250 characters)") @click.option( "--run-refs-action", - type=click.Choice(['add', 'update', 'delete'], case_sensitive=False), - default='add', + type=click.Choice(["add", "update", "delete"], case_sensitive=False), + default="add", metavar="", - help="Action to perform on references: 'add' (default), 'update' (replace all), or 'delete' (remove all or specific)" + help="Action to perform on references: 'add' (default), 'update' (replace all), or 'delete' (remove all or specific)", ) @click.option("-f", "--file", type=click.Path(), metavar="", help="Write run data to file.") @click.pass_context @@ -121,18 +119,32 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): environment.cmd = "add_run" environment.set_parameters(context) environment.check_for_required_parameters() - + + # Validation: mutually exclusive assignee flags + if environment.run_assigned_to_id and environment.clear_run_assigned_to_id: + environment.elog("Error: --run-assigned-to-id and --clear-run-assigned-to-id cannot be used together.") + exit(1) + + # Validation: clear flag requires --run-id (update mode only) + if environment.clear_run_assigned_to_id and not environment.run_id: + environment.elog( + "Error: --clear-run-assigned-to-id can only be used when updating an existing run (--run-id required)." + ) + exit(1) + if environment.run_refs and len(environment.run_refs) > 250: environment.elog("Error: References field cannot exceed 250 characters.") exit(1) - - if environment.run_refs_action and environment.run_refs_action != 'add' and not environment.run_id: - environment.elog("Error: --run-refs-action 'update' and 'delete' can only be used when updating an existing run (--run-id required).") + + if environment.run_refs_action and environment.run_refs_action != "add" and not environment.run_id: + environment.elog( + "Error: --run-refs-action 'update' and 'delete' can only be used when updating an existing run (--run-id required)." + ) exit(1) - - if environment.run_refs_action == 'delete' and not environment.run_refs and environment.run_id: + + if environment.run_refs_action == "delete" and not environment.run_refs and environment.run_id: environment.run_refs = "" - + print_config(environment) project_client = ProjectBasedClient( From 7d55f570adfad9327f132750e52c4aed49e3e62e Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 17 Mar 2026 15:20:33 +0800 Subject: [PATCH 3/4] TRCLI-239: Updated unit tests README and Changelog --- CHANGELOG.MD | 1 + README.md | 37 +++++++++++++ tests/test_cmd_add_run.py | 109 +++++++++++++++++++++++++------------- 3 files changed, 109 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 49ee22a..0ff8843 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -12,6 +12,7 @@ _released 03-17-2026 ### Added - Extended glob support for robot parser + - Added `--clear-run-assigned-to-id` flag to `add_run` command for clearing test run assignees during updates ### Fixed - Cannot add empty runs via add_run command due to empty test case validation. diff --git a/README.md b/README.md index 048ba8e..132bfd1 100644 --- a/README.md +++ b/README.md @@ -1640,6 +1640,8 @@ Options: --run-end-date The expected or scheduled end date of this test run in MM/DD/YYYY format --run-assigned-to-id The ID of the user the test run should be assigned to. [x>=1] + --clear-run-assigned-to-id Clear the assignee of the test run (only valid + when updating with --run-id). --run-include-all Use this option to include all test cases in this test run. --auto-close-run Use this option to automatically close the created run. --run-case-ids Comma separated list of test case IDs to include in @@ -1712,6 +1714,41 @@ trcli -y -h https://example.testrail.io/ --project "My Project" \ - **Action Requirements**: `update` and `delete` actions require an existing run (--run-id must be provided) - **Validation**: Invalid reference formats are rejected with clear error messages +### Managing Assignees in Test Runs + +The `add_run` command supports comprehensive assignee management for test runs. You can assign runs to users when creating or updating them, and clear assignees when needed. + +#### Assigning Runs to Users + +When creating a new test run or updating an existing one, you can assign it to a user using the `--run-assigned-to-id` option: + +```bash +# Create a new run and assign to user ID 5 +trcli -y -h https://example.testrail.io/ --project "My Project" \ + add_run --title "My Test Run" --run-assigned-to-id 5 + +# Update an existing run and change the assignee +trcli -y -h https://example.testrail.io/ --project "My Project" \ + add_run --run-id 123 --title "My Test Run" --run-assigned-to-id 10 +``` + +#### Clearing Assignees from Test Runs + +To remove the assignee from an existing test run, use the `--clear-run-assigned-to-id` flag: + +```bash +# Clear the assignee from an existing run +trcli -y -h https://example.testrail.io/ --project "My Project" \ + add_run --run-id 123 --title "My Test Run" --clear-run-assigned-to-id +``` + +#### Assignee Management Rules + +- **Update Mode Only**: The `--clear-run-assigned-to-id` flag can only be used when updating an existing run (requires `--run-id`) +- **Mutually Exclusive**: You cannot use both `--run-assigned-to-id` and `--clear-run-assigned-to-id` in the same command +- **No Change**: If neither flag is provided during an update, the existing assignee remains unchanged +- **User ID Format**: User IDs must be positive integers (x>=1) + #### Examples **Complete Workflow Example:** diff --git a/tests/test_cmd_add_run.py b/tests/test_cmd_add_run.py index 88a52a7..eee18ef 100644 --- a/tests/test_cmd_add_run.py +++ b/tests/test_cmd_add_run.py @@ -42,9 +42,11 @@ def test_write_run_to_file_with_refs_and_description(self, mock_open_file): environment.run_assigned_to_id = assigned_to_id environment.run_case_ids = case_ids environment.run_include_all = True - expected_string = (f"run_assigned_to_id: {assigned_to_id}\nrun_case_ids: '{case_ids}'\n" - f"run_description: {description}\nrun_id: {run_id}\n" - f"run_include_all: true\nrun_refs: {refs}\ntitle: {title}\n") + expected_string = ( + f"run_assigned_to_id: {assigned_to_id}\nrun_case_ids: '{case_ids}'\n" + f"run_description: {description}\nrun_id: {run_id}\n" + f"run_include_all: true\nrun_refs: {refs}\ntitle: {title}\n" + ) cmd_add_run.write_run_to_file(environment, run_id) mock_open_file.assert_called_with(file, "a") mock_open_file.return_value.__enter__().write.assert_called_once_with(expected_string) @@ -52,35 +54,31 @@ def test_write_run_to_file_with_refs_and_description(self, mock_open_file): def test_cli_validation_refs_too_long(self): """Test that references validation fails when exceeding 250 characters""" from trcli.cli import Environment - + environment = Environment() environment.run_refs = "A" * 251 # 251 characters, exceeds limit - + assert len(environment.run_refs) > 250 - + runner = CliRunner() long_refs = "A" * 251 - - result = runner.invoke(cmd_add_run.cli, [ - '--title', 'Test Run', - '--run-refs', long_refs - ], catch_exceptions=False) - + + result = runner.invoke( + cmd_add_run.cli, ["--title", "Test Run", "--run-refs", long_refs], catch_exceptions=False + ) + # Should exit with error code 1 due to missing required parameters or validation assert result.exit_code == 1 def test_cli_validation_refs_exactly_250_chars(self): """Test that references validation passes with exactly 250 characters""" from trcli.cli import Environment - + runner = CliRunner() refs_250 = "A" * 250 # Exactly 250 characters, should pass validation - - result = runner.invoke(cmd_add_run.cli, [ - '--title', 'Test Run', - '--run-refs', refs_250 - ], catch_exceptions=False) - + + result = runner.invoke(cmd_add_run.cli, ["--title", "Test Run", "--run-refs", refs_250], catch_exceptions=False) + # Should not fail due to refs validation (will fail due to missing required parameters) # But the important thing is that it doesn't fail with the character limit error assert "References field cannot exceed 250 characters" not in result.output @@ -88,18 +86,18 @@ def test_cli_validation_refs_exactly_250_chars(self): def test_validation_logic_refs_action_without_run_id(self): """Test validation logic for refs action without run_id""" from trcli.cli import Environment - + # Update action validation environment = Environment() environment.run_refs_action = "update" environment.run_id = None environment.run_refs = "JIRA-123" - + # This should be invalid assert environment.run_refs_action == "update" assert environment.run_id is None - - # Delete action validation + + # Delete action validation environment.run_refs_action = "delete" assert environment.run_refs_action == "delete" assert environment.run_id is None @@ -107,37 +105,72 @@ def test_validation_logic_refs_action_without_run_id(self): def test_refs_action_parameter_parsing(self): """Test that refs action parameter is parsed correctly""" runner = CliRunner() - + # Test that the CLI accepts new param without crashing! :) - acuanico - result = runner.invoke(cmd_add_run.cli, ['--help']) + result = runner.invoke(cmd_add_run.cli, ["--help"]) assert result.exit_code == 0 assert "--run-refs-action" in result.output assert "Action to perform on references" in result.output + def test_clear_assigned_to_id_parameter_exists(self): + """Test that --clear-run-assigned-to-id parameter is available""" + runner = CliRunner() + + result = runner.invoke(cmd_add_run.cli, ["--help"]) + assert result.exit_code == 0 + assert "--clear-run-assigned-to-id" in result.output + assert "Clear the assignee" in result.output + + @mock.patch("trcli.cli.Environment.check_for_required_parameters") + def test_clear_assigned_to_id_requires_run_id(self, mock_check): + """Test that --clear-run-assigned-to-id requires --run-id""" + runner = CliRunner() + + result = runner.invoke(cmd_add_run.cli, ["--title", "Test Run", "--clear-run-assigned-to-id"]) + + assert result.exit_code == 1 + assert ( + "--clear-run-assigned-to-id can only be used when updating" in result.output + or "--run-id required" in result.output + ) + + @mock.patch("trcli.cli.Environment.check_for_required_parameters") + def test_clear_assigned_to_id_mutually_exclusive_with_assigned_to_id(self, mock_check): + """Test that --clear-run-assigned-to-id and --run-assigned-to-id are mutually exclusive""" + runner = CliRunner() + + result = runner.invoke( + cmd_add_run.cli, + ["--title", "Test Run", "--run-id", "123", "--run-assigned-to-id", "42", "--clear-run-assigned-to-id"], + ) + + assert result.exit_code == 1 + assert "cannot be used together" in result.output + class TestApiRequestHandlerReferences: """Test class for reference management functionality""" - + def test_manage_references_add(self): """Test adding references to existing ones""" from trcli.api.api_request_handler import ApiRequestHandler from trcli.cli import Environment from trcli.api.api_client import APIClient from trcli.data_classes.dataclass_testrail import TestRailSuite - + environment = Environment() api_client = APIClient("https://test.testrail.com") suite = TestRailSuite(name="Test Suite") handler = ApiRequestHandler(environment, api_client, suite) - + # Adding new references result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-300,JIRA-400", "add") assert result == "JIRA-100,JIRA-200,JIRA-300,JIRA-400" - + # Adding duplicate references (should not duplicate) result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-200,JIRA-300", "add") assert result == "JIRA-100,JIRA-200,JIRA-300" - + # Adding to empty existing references result = handler._manage_references("", "JIRA-100,JIRA-200", "add") assert result == "JIRA-100,JIRA-200" @@ -148,16 +181,16 @@ def test_manage_references_update(self): from trcli.cli import Environment from trcli.api.api_client import APIClient from trcli.data_classes.dataclass_testrail import TestRailSuite - + environment = Environment() api_client = APIClient("https://test.testrail.com") suite = TestRailSuite(name="Test Suite") handler = ApiRequestHandler(environment, api_client, suite) - + # Test replacing all references result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-300,JIRA-400", "update") assert result == "JIRA-300,JIRA-400" - + # Test replacing with empty references result = handler._manage_references("JIRA-100,JIRA-200", "", "update") assert result == "" @@ -168,24 +201,24 @@ def test_manage_references_delete(self): from trcli.cli import Environment from trcli.api.api_client import APIClient from trcli.data_classes.dataclass_testrail import TestRailSuite - + environment = Environment() api_client = APIClient("https://test.testrail.com") suite = TestRailSuite(name="Test Suite") handler = ApiRequestHandler(environment, api_client, suite) - + # Deleting specific references result = handler._manage_references("JIRA-100,JIRA-200,JIRA-300", "JIRA-200", "delete") assert result == "JIRA-100,JIRA-300" - + # Deleting multiple specific references result = handler._manage_references("JIRA-100,JIRA-200,JIRA-300,JIRA-400", "JIRA-200,JIRA-400", "delete") assert result == "JIRA-100,JIRA-300" - + # Deleting all references (empty new_refs) result = handler._manage_references("JIRA-100,JIRA-200", "", "delete") assert result == "" - + # Deleting non-existent references result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-999", "delete") assert result == "JIRA-100,JIRA-200" From 801e55d0288f0c3188acb2840318f24244e6f49d Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 17 Mar 2026 15:21:23 +0800 Subject: [PATCH 4/4] TRCLI-239: Revised README file guide --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 132bfd1..52d0e22 100644 --- a/README.md +++ b/README.md @@ -1746,8 +1746,6 @@ trcli -y -h https://example.testrail.io/ --project "My Project" \ - **Update Mode Only**: The `--clear-run-assigned-to-id` flag can only be used when updating an existing run (requires `--run-id`) - **Mutually Exclusive**: You cannot use both `--run-assigned-to-id` and `--clear-run-assigned-to-id` in the same command -- **No Change**: If neither flag is provided during an update, the existing assignee remains unchanged -- **User ID Format**: User IDs must be positive integers (x>=1) #### Examples