Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1712,6 +1714,39 @@ 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

#### Examples

**Complete Workflow Example:**
Expand Down
109 changes: 71 additions & 38 deletions tests/test_cmd_add_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,102 +42,135 @@ 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)

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

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

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"
Expand All @@ -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 == ""
Expand All @@ -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"
5 changes: 4 additions & 1 deletion trcli/api/api_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions trcli/api/project_based_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions trcli/api/run_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -129,17 +135,19 @@ 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

: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}")
Expand All @@ -161,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

Expand Down
Loading
Loading