Skip to content
Open
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
44 changes: 44 additions & 0 deletions trcli/api/api_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,50 @@ def check_automation_id_field(self, project_id: int) -> Union[str, None]:
else:
return response.error_message

def automation_id_field_exists(self, project_id: int) -> bool:
"""
Checks whether the automation_id field exists at all (under either known system
name), regardless of whether it's active/in-scope for ``project_id``.

Used to decide whether to offer field auto-creation (field genuinely absent) vs.
showing the static "fix its configuration" message (field exists but is inactive
or not scoped to this project - creating a duplicate wouldn't help and TestRail
does not allow two custom fields with the same system name anyway).

:param project_id: the id of the project (unused directly here, kept for a
consistent signature alongside check_automation_id_field)
:return: True if a field with the automation_id system name exists, else False
"""
response = self.client.send_get("get_case_fields")
if response.error_message:
return False
fields: List = response.response_text
return any(
field["system_name"] in [OLD_SYSTEM_NAME_AUTOMATION_ID, UPDATED_SYSTEM_NAME_AUTOMATION_ID]
for field in fields
)

def add_automation_id_field(self, project_id: int) -> Tuple[dict, str, int]:
"""
Create the missing automation_id custom case field, scoped to ``project_id``
(project-scoped, not global - the safer default: it only affects this one
project rather than silently mutating every project in the TestRail instance).

Callers must only invoke this after explicit user confirmation.

:param project_id: the id of the project to scope the new field to
:return: Tuple with (created_field_dict, error_message, status_code)
"""
# TestRail auto-prefixes "custom_" to the name we pass, so a literal
# "automation_id" here produces system_name "custom_automation_id"
return self.case_field_handler.add_case_field(
field_type="String",
name="automation_id",
label="Automation ID",
project_ids=[project_id],
is_global=False,
)

def get_project_data(self, project_name: str, project_id: int = None) -> ProjectData:
"""
Send get_projects with project name
Expand Down
58 changes: 57 additions & 1 deletion trcli/api/case_field_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

It manages case field query operations including:
- Retrieving all available test case custom fields
- Creating new test case custom fields (e.g. auto-creating a missing
automation_id field - see ApiRequestHandler.add_automation_id_field)
"""

from beartype.typing import Tuple
from beartype.typing import List, Optional, Tuple

from trcli.api.api_client import APIClient

Expand All @@ -31,3 +33,57 @@ def get_case_fields(self) -> Tuple[list, str]:
if response.error_message:
return [], response.error_message
return response.response_text, ""

def add_case_field(
self,
field_type: str,
name: str,
label: str,
project_ids: Optional[List[int]] = None,
is_global: bool = False,
) -> Tuple[dict, str, int]:
"""
Create a new test case custom field via ``POST add_case_field``.

This is an admin-level, instance-wide-visible action in TestRail (creating a
custom field affects every project it's scoped to) - callers must only invoke
this after explicit user confirmation (see
``ProjectBasedClient.resolve_project``'s automation_id auto-creation prompt),
never silently.

:param field_type: TestRail field type string. "String" confirmed valid against a
live TestRail instance (trcliaieval staging, 2026-10-05 spike) - creates a
working plain-text field.
:param name: system name for the field (without the "custom_" prefix TestRail
adds automatically), e.g. "automation_id".
:param label: human-readable label shown in the TestRail UI, e.g. "Automation ID".
:param project_ids: list of project IDs to scope this field to. Ignored when
``is_global`` is True. A project-scoped field (``is_global=False``) is the
safer default - it only affects the specified project(s) rather than every
project in the TestRail instance.
:param is_global: when True, the field is created for every project instance-wide.
:returns: Tuple with (created_field_dict, error_message, status_code). On
failure, created_field_dict is {}. status_code lets callers distinguish a
permission error (403 - field management requires TestRail admin rights)
from other failures.
"""
payload = {
"type": field_type,
"name": name,
"label": label,
"configs": [
{
"context": {
"is_global": is_global,
"project_ids": [] if is_global else (project_ids or []),
},
"options": {"is_required": False},
}
],
# NOTE: must always be True, independent of is_global.
"include_all": True,
}
response = self.client.send_post("add_case_field", payload)
if response.error_message:
return {}, response.error_message, response.status_code
return response.response_text, "", response.status_code
49 changes: 43 additions & 6 deletions trcli/api/project_based_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,49 @@ def resolve_project(self):
self.environment.log("Checking project. ", new_line=False)
self.project = self.api_request_handler.get_project_data(self.environment.project, self.environment.project_id)
self._validate_project_id()
if self.environment.auto_creation_response:
if self.environment.case_matcher == MatchersParser.AUTO:
automation_id_error = self.api_request_handler.check_automation_id_field(self.project.project_id)
if automation_id_error:
self.environment.elog(automation_id_error)
exit(1)
if self.environment.case_matcher == MatchersParser.AUTO:
# NOTE: must NOT be gated behind `self.environment.auto_creation_response` here.
self._check_or_create_automation_id_field()
self.environment.log("Done.")

def _check_or_create_automation_id_field(self):
"""
Checks the automation_id field is properly configured for this project.

Exits the process (code 1) if the field is unavailable and either the user
declines creation, lacks permission to create it, or creation fails for any
other reason.
"""
automation_id_error = self.api_request_handler.check_automation_id_field(self.project.project_id)
if not automation_id_error:
return

if self.api_request_handler.automation_id_field_exists(self.project.project_id):
self.environment.elog(automation_id_error)
exit(1)

prompt_message = PROMPT_MESSAGES["create_automation_id_field"].format(
project_name=self.environment.project or self.project.project_id,
)
if not self.environment.get_prompt_response_for_auto_creation(prompt_message):
self.environment.elog(FAULT_MAPPING["no_user_agreement"].format(type="automation_id field"))
exit(1)

self.environment.log("Creating automation_id field. ", new_line=False)
_, create_error, status_code = self.api_request_handler.add_automation_id_field(self.project.project_id)
if create_error:
if status_code == 403:
self.environment.elog(FAULT_MAPPING["automation_id_creation_permission_denied"])
else:
self.environment.elog(FAULT_MAPPING["automation_id_creation_failed"].format(error_message=create_error))
exit(1)

# Re-check so _active_automation_id_field gets set on both self.api_request_handler
# and its case_handler before any case matching/creation happens.
automation_id_error = self.api_request_handler.check_automation_id_field(self.project.project_id)
if automation_id_error:
self.environment.elog(automation_id_error)
exit(1)
self.environment.log("Done.")

def _validate_project_id(self):
Expand Down
3 changes: 2 additions & 1 deletion trcli/api/results_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ def upload_results(self):

added_sections = None
added_test_cases = None
if self.environment.auto_creation_response and not all_cases_have_ids:
if not all_cases_have_ids:
# NOTE: must NOT be gated behind `self.environment.auto_creation_response` here.
added_sections, result_code = self.add_missing_sections(self.project.project_id)
if result_code == -1:
revert_logs = self.rollback_changes(
Expand Down
10 changes: 10 additions & 0 deletions trcli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@
f" - System Name: automation_id\n"
f" - Type: Text (or String)\n"
f" - Is Active: True",
automation_id_creation_permission_denied="Unable to create the automation_id field: TestRail reported a "
"permission error. Creating custom case fields requires TestRail Administrator rights. Please ask a "
"TestRail admin to create it for you, or configure it manually in the TestRail Administration under "
"Customizations > Case Fields (System Name: automation_id, Type: Text/String, Is Active: True).",
automation_id_creation_failed="Unable to create the automation_id field automatically: {error_message}\n"
"Please configure it manually in the TestRail Administration under Customizations > Case Fields "
"(System Name: automation_id, Type: Text/String, Is Active: True).",
proxy_connection_error="Failed to connect to the proxy server. Please check the proxy settings and ensure the server is available.",
proxy_authentication_failed="Proxy authentication failed for proxy. Please verify the username and password.",
proxy_timeout="The connection to the proxy server timed out. Please try again later or check the proxy server's availability.",
Expand Down Expand Up @@ -118,6 +125,9 @@
create_missing_test_cases="Some of the test cases in provided file are missing "
"in TestRail or the IDs are not specified.\n"
"Would you like to create missing test cases under project: '{project_name}'?",
create_automation_id_field="The automation_id field was not found in TestRail.\n"
"Would you like to create it now, scoped to project '{project_name}'? "
"(This requires TestRail Administrator rights.)",
)

TOOL_VERSION = f"""TestRail CLI v{trcli.__version__}
Expand Down
Loading