From 6c60240375d56783cdd2a6ddc9c6cd191c67bcb9 Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Fri, 12 Jun 2026 16:00:48 +0800 Subject: [PATCH] TRCLI-278: Implemented runs command for querying runs data from TestRail --- CHANGELOG.MD | 2 +- README.md | 79 ++++++ tests/test_cmd_runs.py | 413 +++++++++++++++++++++++++++++++ tests/test_data/cli_test_data.py | 6 + trcli/api/run_handler.py | 46 ++++ trcli/commands/cmd_runs.py | 315 +++++++++++++++++++++++ trcli/constants.py | 9 +- 7 files changed, 868 insertions(+), 2 deletions(-) create mode 100644 tests/test_cmd_runs.py create mode 100644 trcli/commands/cmd_runs.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 632de832..8b902408 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb _released 06-24-2026 ### Added - - **Added more data query commands for comprehensive AI automation support** + - **New `runs` command**: Query and retrieve test run information from TestRail with `get` and `list` subcommands. Provides execution metrics, status tracking, and configuration details. Note: Only returns standalone runs (not runs within test plans). ## [1.15.0] diff --git a/README.md b/README.md index fee1cbbb..8851c486 100644 --- a/README.md +++ b/README.md @@ -2025,6 +2025,85 @@ $ trcli sections list -c config.yml --json-output | jq '.sections[].name' $ trcli sections list -c config.yml --show-all-fields ``` +### Managing Runs + +The TestRail CLI provides the `runs` command for retrieving and listing test runs from TestRail. Test runs represent the execution of a set of test cases, tracking their results and providing execution metrics. This command is useful for monitoring test execution progress, auditing run configurations, exporting run data, and integrating run information into automation workflows. + +**Note:** The `runs` command only returns standalone test runs (runs not part of a test plan). To query runs within test plans, use the `plans` command. + +### Runs Command Overview + +The `runs` command supports two subcommands: + +| Subcommand | Purpose | +|------------|---------| +| `runs get` | Retrieve a single run by ID | Get detailed information about a run including execution status, test counts, and configuration | +| `runs list` | List runs in a project | Monitor active runs, track execution progress, and export run data | + +### Reference + +```shell + +$ trcli runs --help + +Usage: trcli runs [OPTIONS] COMMAND [ARGS]... + Manage runs in TestRail + +Options: + --help Show this message and exit. + +Commands: + get Get a single run by ID + list List runs from TestRail +``` + +##### Retrieving a Single Run + +Get detailed information about a specific run by its ID: + +```shell +# Get a run (using config file) +$ trcli runs get -c config.yml --run-id 100 + +# Get a run with all parameters +$ trcli runs get \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" \ + --run-id 100 + +# Get run with all fields displayed (includes timestamps, config IDs, milestone, etc.) +$ trcli runs get -c config.yml --run-id 100 --show-all-fields + +# Get run as JSON (for piping to jq or other tools) +$ trcli runs get -c config.yml --run-id 100 --json-output +``` +##### Listing Runs + +List runs from a project with pagination support: + +```shell +# List all runs in a project (using config file) +$ trcli runs list -c config.yml + +# List all runs with all parameters +$ trcli runs list \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" + +# Pagination support +$ trcli runs list -c config.yml --offset 0 --limit 50 + +# JSON output for integration with other tools +$ trcli runs list -c config.yml --json-output | jq '.runs[].name' + +# Show all fields for each run +$ trcli runs list -c config.yml --show-all-fields +``` + #### Labels Management The TestRail CLI provides comprehensive label management capabilities using the `labels` command. Labels help categorize and organize your test management assets efficiently, making it easier to filter and manage test cases, runs, and projects. diff --git a/tests/test_cmd_runs.py b/tests/test_cmd_runs.py new file mode 100644 index 00000000..91cebc54 --- /dev/null +++ b/tests/test_cmd_runs.py @@ -0,0 +1,413 @@ +import pytest +from unittest import mock +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from trcli.cli import Environment +from trcli.commands import cmd_runs + + +class TestCmdRuns: + """Test class for runs command functionality""" + + def setup_method(self): + """Set up test environment""" + self.runner = CliRunner() + self.environment = Environment(cmd="runs") + self.environment.host = "https://test.testrail.com" + self.environment.username = "test@example.com" + self.environment.password = "password" + self.environment.project = "Test Project" + self.environment.project_id = 1 + + def _setup_project_client_mock(self, mock_project_client, project_id=1): + """Helper to setup ProjectBasedClient mock""" + mock_client_instance = MagicMock() + mock_project_client.return_value = mock_client_instance + mock_client_instance.project.project_id = project_id + return mock_client_instance + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_get_run_success(self, mock_project_client): + """Test successful run retrieval""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_run.return_value = ( + { + "id": 81, + "name": "File Formats", + "description": "Test file format handling", + "suite_id": 4, + "project_id": 1, + "is_completed": False, + "completed_on": None, + "passed_count": 2, + "failed_count": 2, + "blocked_count": 0, + "retest_count": 1, + "untested_count": 3, + "config": "Firefox, Ubuntu 12", + "config_ids": [2, 6], + "milestone_id": 7, + "plan_id": 80, + "assignedto_id": 6, + "refs": "SAN-1", + "include_all": False, + "created_by": 1, + "created_on": 1393845644, + "url": "http://test.testrail.com/index.php?/runs/view/81", + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.get, ["--run-id", "81"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.run_handler.get_run.assert_called_once_with(81) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_get_run_json_output(self, mock_project_client): + """Test run retrieval with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + run_data = { + "id": 81, + "name": "File Formats", + "suite_id": 4, + "is_completed": False, + "passed_count": 2, + "failed_count": 1, + } + mock_client.api_request_handler.run_handler.get_run.return_value = (run_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_runs.get, ["--run-id", "81", "--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"id": 81' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_get_run_show_all_fields(self, mock_project_client): + """Test run retrieval with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_run.return_value = ( + { + "id": 81, + "name": "File Formats", + "description": "Test description", + "suite_id": 4, + "project_id": 1, + "is_completed": False, + "passed_count": 2, + "failed_count": 1, + "blocked_count": 0, + "retest_count": 0, + "untested_count": 3, + "config": "Firefox, Ubuntu 12", + "config_ids": [2, 6], + "milestone_id": 7, + "plan_id": 80, + "assignedto_id": 6, + "refs": "SAN-1", + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.get, ["--run-id", "81", "--show-all-fields"], obj=self.environment) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_get_run_api_error(self, mock_project_client): + """Test run retrieval with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_run.return_value = ({}, "Run not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.get, ["--run-id", "999"], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve run: Run not found") + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_success(self, mock_project_client): + """Test successful runs listing""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "runs": [ + { + "id": 81, + "name": "File Formats", + "suite_id": 4, + "is_completed": False, + "passed_count": 2, + "failed_count": 2, + "blocked_count": 0, + "untested_count": 3, + }, + { + "id": 82, + "name": "System Tests", + "suite_id": 5, + "is_completed": True, + "passed_count": 10, + "failed_count": 0, + "blocked_count": 0, + "untested_count": 0, + }, + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.run_handler.get_runs.assert_called_once_with( + project_id=1, limit=250, offset=0 + ) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_with_pagination(self, mock_project_client): + """Test runs listing with pagination parameters""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + { + "offset": 100, + "limit": 50, + "size": 50, + "_links": { + "next": "/api/v2/get_runs/1&offset=150", + "prev": "/api/v2/get_runs/1&offset=50", + }, + "runs": [{"id": i, "name": f"Run {i}", "suite_id": 1, "is_completed": False} for i in range(100, 150)], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, ["--offset", "100", "--limit", "50"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.run_handler.get_runs.assert_called_once_with( + project_id=1, limit=50, offset=100 + ) + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_json_output(self, mock_project_client): + """Test runs listing with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + response_data = { + "offset": 0, + "limit": 250, + "size": 1, + "runs": [{"id": 81, "name": "Test", "suite_id": 1, "is_completed": False}], + } + mock_client.api_request_handler.run_handler.get_runs.return_value = (response_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_runs.list, ["--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"offset": 0' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_show_all_fields(self, mock_project_client): + """Test runs listing with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 1, + "runs": [ + { + "id": 81, + "name": "File Formats", + "description": "Test description", + "suite_id": 4, + "is_completed": False, + "passed_count": 2, + "failed_count": 1, + "blocked_count": 0, + "untested_count": 3, + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, ["--show-all-fields"], obj=self.environment) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_empty_result(self, mock_project_client): + """Test runs listing with empty result""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + {"offset": 0, "limit": 250, "size": 0, "runs": []}, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_log.assert_any_call("No runs found.") + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_api_error(self, mock_project_client): + """Test runs listing with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_runs.return_value = ({}, "Project not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, [], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve runs: Project not found") + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_with_next_link(self, mock_project_client): + """Test runs listing shows pagination hint when next link is present""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 250, + "_links": {"next": "/api/v2/get_runs/1&offset=250", "prev": None}, + "runs": [{"id": i, "name": f"Run {i}", "suite_id": 1, "is_completed": False} for i in range(1, 251)], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, [], obj=self.environment) + + assert result.exit_code == 0 + log_calls = [str(call) for call in mock_log.call_args_list] + pagination_hint_found = any("More results available" in str(call) for call in log_calls) + assert pagination_hint_found + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_get_run_with_project_id_from_config(self, mock_project_client): + """Test run retrieval uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + run_data = {"id": 81, "name": "File Formats", "suite_id": 4, "is_completed": False} + mock_client.api_request_handler.run_handler.get_run.return_value = (run_data, "") + + self.environment.project_id = 42 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.get, ["--run-id", "81"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.run_handler.get_run.assert_called_once_with(81) + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_with_project_id_from_config(self, mock_project_client): + """Test runs listing uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "runs": [{"id": 81, "name": "Test", "suite_id": 1}]}, + "", + ) + + self.environment.project_id = 99 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.run_handler.get_runs.assert_called_once_with( + project_id=99, limit=250, offset=0 + ) + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_get_run_with_project_name(self, mock_project_client): + """Test run retrieval with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + run_data = {"id": 81, "name": "File Formats", "suite_id": 4, "is_completed": False} + mock_client.api_request_handler.run_handler.get_run.return_value = (run_data, "") + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.get, ["--run-id", "81"], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.run_handler.get_run.assert_called_once_with(81) + + @mock.patch("trcli.commands.cmd_runs.ProjectBasedClient") + def test_list_runs_with_project_name(self, mock_project_client): + """Test runs listing with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.run_handler.get_runs.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "runs": [{"id": 81, "name": "Test", "suite_id": 1}]}, + "", + ) + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_runs.list, [], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.run_handler.get_runs.assert_called_once_with( + project_id=99, limit=250, offset=0 + ) diff --git a/tests/test_data/cli_test_data.py b/tests/test_data/cli_test_data.py index 6cb09ea6..655d70e0 100644 --- a/tests/test_data/cli_test_data.py +++ b/tests/test_data/cli_test_data.py @@ -73,6 +73,12 @@ " - add_run: Create a new test run\n" " - labels: Manage labels (projects, cases, and tests)\n" " - references: Manage references (cases and runs)\n" + " - cases: Query test cases\n" + " - suites: Query test suites\n" + " - sections: Query test sections\n" + " - plans: Query test plans\n" + " - runs: Query test runs\n" + " - results: Query and update test results\n" ) trcli_help_description = "TestRail CLI" diff --git a/trcli/api/run_handler.py b/trcli/api/run_handler.py index 71241fbc..161fd9b8 100644 --- a/trcli/api/run_handler.py +++ b/trcli/api/run_handler.py @@ -376,6 +376,52 @@ def delete_run(self, run_id: int) -> Tuple[dict, str]: response = self.client.send_post(f"delete_run/{run_id}", payload={}) return response.response_text, response.error_message + def get_run(self, run_id: int) -> Tuple[dict, str]: + """ + Retrieve a single test run by ID + + :param run_id: TestRail run ID + :returns: Tuple with (run_data_dict, error_message) + """ + response = self.client.send_get(f"get_run/{run_id}") + if response.error_message: + return {}, response.error_message + return response.response_text, "" + + def get_runs( + self, + project_id: int, + limit: int = 250, + offset: int = 0, + ) -> Tuple[dict, str]: + """ + Retrieve test runs for a project with pagination. + Only returns test runs that are not part of a test plan. + + :param project_id: TestRail project ID + :param limit: Maximum number of runs to return (default: 250) + :param offset: Offset for pagination (default: 0) + :returns: Tuple with (paginated_response_dict, error_message) + Response dict contains: runs, offset, limit, size, _links + """ + # Build query parameters + params = [] + if limit != 250: + params.append(f"limit={limit}") + if offset > 0: + params.append(f"offset={offset}") + + # Build URL + query_string = "&".join(params) if params else "" + url = f"get_runs/{project_id}" + if query_string: + url = f"{url}&{query_string}" + + response = self.client.send_get(url) + if response.error_message: + return {}, response.error_message + return response.response_text, "" + def add_plan( self, project_id: int, diff --git a/trcli/commands/cmd_runs.py b/trcli/commands/cmd_runs.py new file mode 100644 index 00000000..2cce1a23 --- /dev/null +++ b/trcli/commands/cmd_runs.py @@ -0,0 +1,315 @@ +import builtins +import click +import json + +from trcli.api.project_based_client import ProjectBasedClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +def print_config(env: Environment, action: str): + env.log( + f"Runs {action} Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + ) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Manage test runs in TestRail""" + environment.cmd = "runs" + environment.set_parameters(context) + + +@cli.command() +@click.option("--run-id", type=click.IntRange(min=1), required=True, metavar="", help="Run ID to retrieve.") +@click.option("--json-output", is_flag=True, help="Output run as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def get( + environment: Environment, + context: click.Context, + run_id: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """Get a single test run by ID""" + environment.check_for_required_parameters() + + print_config(environment, "Get") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving run ID {run_id}...") + + # Retrieve the run using RunHandler from ProjectBasedClient + run_data, error_message = project_client.api_request_handler.run_handler.get_run(run_id) + + if error_message: + environment.elog(f"Error: Failed to retrieve run: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(run_data, indent=2)) + else: + # Display run details + environment.log("") + environment.log(f"Run ID: {run_data.get('id', 'N/A')}") + environment.log(f" Name: {run_data.get('name', 'N/A')}") + + if run_data.get("description"): + description = run_data.get("description") + # Truncate long descriptions in non-show-all-fields mode + if not show_all_fields and len(description) > 100: + description = description[:100] + "..." + environment.log(f" Description: {description}") + else: + environment.log(" Description: (none)") + + environment.log(f" Suite ID: {run_data.get('suite_id', 'N/A')}") + environment.log(f" Project ID: {run_data.get('project_id', 'N/A')}") + + # Status information + is_completed = run_data.get("is_completed", False) + environment.log(f" Status: {'Completed' if is_completed else 'Active'}") + + if is_completed and run_data.get("completed_on"): + environment.log(f" Completed On: {run_data.get('completed_on')}") + + # Test counts + environment.log(" Test Status Counts:") + environment.log(f" Passed: {run_data.get('passed_count', 0)}") + environment.log(f" Failed: {run_data.get('failed_count', 0)}") + environment.log(f" Blocked: {run_data.get('blocked_count', 0)}") + environment.log(f" Retest: {run_data.get('retest_count', 0)}") + environment.log(f" Untested: {run_data.get('untested_count', 0)}") + + if show_all_fields: + # Show additional fields + if run_data.get("config"): + environment.log(f" Configuration: {run_data.get('config')}") + + if run_data.get("config_ids"): + environment.log(f" Configuration IDs: {run_data.get('config_ids')}") + + if run_data.get("milestone_id"): + environment.log(f" Milestone ID: {run_data.get('milestone_id')}") + + if run_data.get("plan_id"): + environment.log(f" Plan ID: {run_data.get('plan_id')}") + + if run_data.get("assignedto_id"): + environment.log(f" Assigned To ID: {run_data.get('assignedto_id')}") + + if run_data.get("refs"): + environment.log(f" References: {run_data.get('refs')}") + + environment.log(f" Include All: {'Yes' if run_data.get('include_all') else 'No'}") + environment.log(f" Created By: {run_data.get('created_by', 'N/A')}") + environment.log(f" Created On: {run_data.get('created_on', 'N/A')}") + + if run_data.get("updated_on"): + environment.log(f" Updated On: {run_data.get('updated_on')}") + + if run_data.get("url"): + environment.log(f" URL: {run_data.get('url')}") + + # Show custom status counts + custom_counts = { + k: v for k, v in run_data.items() if k.startswith("custom_status") and k.endswith("_count") + } + if any(v > 0 for v in custom_counts.values()): + environment.log(" Custom Status Counts:") + for key, value in custom_counts.items(): + if value > 0: + status_num = key.replace("custom_status", "").replace("_count", "") + environment.log(f" Custom Status {status_num}: {value}") + + # Show all other fields + standard_fields = [ + "id", + "name", + "description", + "suite_id", + "project_id", + "is_completed", + "completed_on", + "passed_count", + "failed_count", + "blocked_count", + "retest_count", + "untested_count", + "config", + "config_ids", + "milestone_id", + "plan_id", + "assignedto_id", + "refs", + "include_all", + "created_by", + "created_on", + "updated_on", + "url", + "custom_status1_count", + "custom_status2_count", + "custom_status3_count", + "custom_status4_count", + "custom_status5_count", + "custom_status6_count", + "custom_status7_count", + ] + other_fields = {k: v for k, v in run_data.items() if k not in standard_fields} + if other_fields: + environment.log(f"\n Additional Fields ({len(other_fields)}):") + for key, value in other_fields.items(): + display_name = key.replace("_", " ").title() + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + environment.log(f" {display_name}: {display_value}") + + +@cli.command() +@click.option("--offset", type=int, default=0, metavar="", help="Offset for pagination (default: 0).") +@click.option("--limit", type=int, default=250, metavar="", help="Limit for pagination (default: 250).") +@click.option("--json-output", is_flag=True, help="Output runs as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def list( + environment: Environment, + context: click.Context, + offset: int, + limit: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """List test runs from TestRail""" + environment.check_for_required_parameters() + + print_config(environment, "List") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving runs for project ID {project_client.project.project_id}...") + environment.log("(Note: Only returns runs not part of test plans)") + + # Retrieve runs using RunHandler from ProjectBasedClient + response_data, error_message = project_client.api_request_handler.run_handler.get_runs( + project_id=project_client.project.project_id, + limit=limit, + offset=offset, + ) + + if error_message: + environment.elog(f"Error: Failed to retrieve runs: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(response_data, indent=2)) + else: + # Display runs line by line + runs = response_data.get("runs", []) + response_offset = response_data.get("offset", 0) + response_limit = response_data.get("limit", 250) + response_size = response_data.get("size", 0) + next_link = response_data.get("_links", {}).get("next") + + if not runs: + environment.log("No runs found.") + else: + environment.log( + f"Found {response_size} run(s) (showing {response_offset + 1}-{response_offset + len(runs)}):" + ) + if next_link: + environment.log(" (More results available - use --offset and --limit for pagination)") + environment.log("") + + for run in runs: + if show_all_fields: + # Show all fields from API response + environment.log(f" Run ID: {run.get('id', 'N/A')}") + + # Iterate through all fields in the run + for key, value in run.items(): + if key == "id": + continue # Already displayed as Run ID + + # Format field name for display + display_name = key.replace("_", " ").title() + + # Handle None values + if value is None: + display_value = "N/A" + elif isinstance(value, bool): + display_value = "Yes" if value else "No" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + + environment.log(f" {display_name}: {display_value}") + + environment.log("") + else: + # Display compact format + environment.log(f" Run ID: {run.get('id', 'N/A')}") + environment.log(f" Name: {run.get('name', 'N/A')}") + + if run.get("description"): + description = run.get("description") + # Truncate long descriptions in compact mode + if len(description) > 80: + description = description[:80] + "..." + environment.log(f" Description: {description}") + + is_completed = run.get("is_completed", False) + environment.log(f" Status: {'Completed' if is_completed else 'Active'}") + + # Show test counts + passed = run.get("passed_count", 0) + failed = run.get("failed_count", 0) + blocked = run.get("blocked_count", 0) + untested = run.get("untested_count", 0) + environment.log( + f" Tests: Passed={passed}, Failed={failed}, Blocked={blocked}, Untested={untested}" + ) + + environment.log(f" Suite ID: {run.get('suite_id', 'N/A')}") + + environment.log("") diff --git a/trcli/constants.py b/trcli/constants.py index 60ef92a6..8b79cbe2 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -94,6 +94,7 @@ suites=dict(**FAULT_MAPPING), plans=dict(**FAULT_MAPPING), sections=dict(**FAULT_MAPPING), + runs=dict(**FAULT_MAPPING), ) PROMPT_MESSAGES = dict( @@ -119,7 +120,13 @@ - parse_openapi: OpenAPI YML Files - add_run: Create a new test run - labels: Manage labels (projects, cases, and tests) - - references: Manage references (cases and runs)""" + - references: Manage references (cases and runs) + - cases: Query test cases + - suites: Query test suites + - sections: Query test sections + - plans: Query test plans + - runs: Query test runs + - results: Query and update test results""" MISSING_COMMAND_SLOGAN = """Usage: trcli [OPTIONS] COMMAND [ARGS]...\nTry 'trcli --help' for help. \nError: Missing command."""