iflow-mcp_sker65-testrail-mcp 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/METADATA +190 -0
- iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/RECORD +10 -0
- iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/WHEEL +4 -0
- iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/entry_points.txt +2 -0
- iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/licenses/LICENSE +21 -0
- testrail_mcp/__init__.py +1 -0
- testrail_mcp/__main__.py +14 -0
- testrail_mcp/config.py +18 -0
- testrail_mcp/mcp_server.py +645 -0
- testrail_mcp/testrail_client.py +216 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iflow-mcp_sker65-testrail-mcp
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: TestRail MCP Server
|
|
5
|
+
Author-email: Stefan Rinke <sker65@gmail.com>
|
|
6
|
+
Maintainer-email: Stefan Rinke <sker65@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: fastmcp
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
12
|
+
Requires-Dist: python-mcp>=1.0.0
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# TestRail MCP Server
|
|
17
|
+
|
|
18
|
+
[](https://smithery.ai/server/@sker65/testrail-mcp)
|
|
19
|
+
|
|
20
|
+
A Model Context Protocol (MCP) server for TestRail that allows interaction with TestRail's core entities through a standardized protocol.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- Authentication with TestRail API
|
|
25
|
+
- Access to TestRail entities:
|
|
26
|
+
- Projects
|
|
27
|
+
- Cases
|
|
28
|
+
- Runs
|
|
29
|
+
- Results
|
|
30
|
+
- Datasets
|
|
31
|
+
- Full support for the Model Context Protocol
|
|
32
|
+
- Compatible with any MCP client (Claude Desktop, Cursor, Windsurf, etc.)
|
|
33
|
+
|
|
34
|
+
## See it in action together with Octomind MCP
|
|
35
|
+
|
|
36
|
+
[](https://www.youtube.com/watch?v=I7lc9I0S62Y)
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
### Installing via Smithery
|
|
41
|
+
|
|
42
|
+
To install testrail-mcp for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@sker65/testrail-mcp):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx -y @smithery/cli install @sker65/testrail-mcp --client claude
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Manual Installation
|
|
49
|
+
1. Clone this repository:
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/yourusername/testrail-mcp.git
|
|
52
|
+
cd testrail-mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
2. Create and activate a virtual environment:
|
|
56
|
+
```bash
|
|
57
|
+
python -m venv .venv
|
|
58
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
3. Install dependencies:
|
|
62
|
+
```bash
|
|
63
|
+
pip install -e .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
The TestRail MCP server requires specific environment variables to authenticate with your TestRail instance. These must be set before running the server.
|
|
69
|
+
|
|
70
|
+
1. Create a `.env` file in the root directory of the project:
|
|
71
|
+
```
|
|
72
|
+
TESTRAIL_URL=https://your-instance.testrail.io
|
|
73
|
+
TESTRAIL_USERNAME=your-email@example.com
|
|
74
|
+
TESTRAIL_API_KEY=your-api-key
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Important Notes:**
|
|
78
|
+
- `TESTRAIL_URL` should be the full URL to your TestRail instance (e.g., `https://example.testrail.io`)
|
|
79
|
+
- `TESTRAIL_USERNAME` is your TestRail email address used for login
|
|
80
|
+
- `TESTRAIL_API_KEY` is your TestRail API key (not your password)
|
|
81
|
+
- To generate an API key, log in to TestRail, go to "My Settings" > "API Keys" and create a new key
|
|
82
|
+
|
|
83
|
+
2. Verify that the configuration is loaded correctly:
|
|
84
|
+
```bash
|
|
85
|
+
uvx testrail-mcp --config
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This will display your TestRail configuration information, including your URL, username, and the first few characters of your API key for verification.
|
|
89
|
+
|
|
90
|
+
If you're using this server with a client like Claude Desktop or Cursor, make sure the environment variables are accessible to the process running the server. You may need to set these variables in your system environment or ensure they're loaded from the `.env` file.
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
### Running the Server
|
|
95
|
+
|
|
96
|
+
The server can be run directly using the installed script:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uvx testrail-mcp
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This will start the MCP server in stdio mode, which can be used with MCP clients that support stdio communication.
|
|
103
|
+
|
|
104
|
+
### Using with MCP Clients
|
|
105
|
+
|
|
106
|
+
#### Claude Desktop
|
|
107
|
+
|
|
108
|
+
In Claude Desktop, add a new server with the following configuration:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"mcpServers": {
|
|
113
|
+
"testrail": {
|
|
114
|
+
"command": "uvx",
|
|
115
|
+
"args": [
|
|
116
|
+
"testrail-mcp"
|
|
117
|
+
],
|
|
118
|
+
"env": {
|
|
119
|
+
"TESTRAIL_URL": "https://your-instance.testrail.io",
|
|
120
|
+
"TESTRAIL_USERNAME": "your-email@example.com",
|
|
121
|
+
"TESTRAIL_API_KEY": "your-api-key"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### Cursor
|
|
129
|
+
|
|
130
|
+
In Cursor, add a new custom tool with the following configuration:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"name": "TestRail MCP",
|
|
135
|
+
"command": "uvx",
|
|
136
|
+
"args": [
|
|
137
|
+
"testrail-mcp"
|
|
138
|
+
],
|
|
139
|
+
"env": {
|
|
140
|
+
"TESTRAIL_URL": "https://your-instance.testrail.io",
|
|
141
|
+
"TESTRAIL_USERNAME": "your-email@example.com",
|
|
142
|
+
"TESTRAIL_API_KEY": "your-api-key"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
#### Windsurf
|
|
148
|
+
|
|
149
|
+
In Windsurf, add a new tool with the following configuration:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"name": "TestRail MCP",
|
|
154
|
+
"command": "uvx",
|
|
155
|
+
"args": [
|
|
156
|
+
"testrail-mcp"
|
|
157
|
+
],
|
|
158
|
+
"env": {
|
|
159
|
+
"TESTRAIL_URL": "https://your-instance.testrail.io",
|
|
160
|
+
"TESTRAIL_USERNAME": "your-email@example.com",
|
|
161
|
+
"TESTRAIL_API_KEY": "your-api-key"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### Testing with MCP Inspector
|
|
167
|
+
|
|
168
|
+
For testing and debugging, you can use the MCP Inspector:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx @modelcontextprotocol/inspector \
|
|
172
|
+
-e TESTRAIL_URL=<your-url> \
|
|
173
|
+
-e TESTRAIL_USERNAME=<your-username> \
|
|
174
|
+
-e TESTRAIL_API_KEY=<your-api-key> \
|
|
175
|
+
uvx testrail-mcp
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This will open a web interface where you can explore and test all the available tools and resources.
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
This server is built using:
|
|
183
|
+
|
|
184
|
+
- [FastMCP](https://github.com/jlowin/fastmcp) - A Python framework for building MCP servers
|
|
185
|
+
- [Requests](https://requests.readthedocs.io/) - For HTTP communication with TestRail API
|
|
186
|
+
- [python-dotenv](https://github.com/theskumar/python-dotenv) - For environment variable management
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
testrail_mcp/__init__.py,sha256=qD-ToZHGgYbGVwzdH4IK2-mrL9DOnGL4VhagARVnhYg,34
|
|
2
|
+
testrail_mcp/__main__.py,sha256=BlQY356TPrUV7W-LH6CfA_vNv6jKlY1r-FU-P8gABaw,388
|
|
3
|
+
testrail_mcp/config.py,sha256=iZ-pp7Ihy3-nIhawusVW675NtYtmpuMFwBGKV62-SV0,578
|
|
4
|
+
testrail_mcp/mcp_server.py,sha256=k2Dl9muaVsjIsu9wmEjfQD9CbEgMGiJ_7iOwVzfqlW4,25959
|
|
5
|
+
testrail_mcp/testrail_client.py,sha256=0MoRDVt6UQoFsUURlmeDr2TWOViNqnhDZyqRDWNTLNY,8613
|
|
6
|
+
iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/METADATA,sha256=HOzp6FsHyiZ_CSy1pbS3xj8UD3oHPPEkc29Lwsheo4M,5097
|
|
7
|
+
iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/entry_points.txt,sha256=0ApB6a60n2oBb9s6d5iP5EbRYinxec0BXSQQ1xz5rEI,60
|
|
9
|
+
iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/licenses/LICENSE,sha256=RLjrd9J2YCGMaJtvLLWONAd_mVmcZscQKmIM2bQMBRw,1069
|
|
10
|
+
iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Stefan Rinke
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
testrail_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TestRail MCP server package."""
|
testrail_mcp/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Entry point for the TestRail MCP server when run as a module."""
|
|
2
|
+
import sys
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from testrail_mcp.mcp_server import TestRailMCPServer
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Run the TestRail MCP server."""
|
|
9
|
+
print("Starting TestRail MCP server in stdio mode", file=sys.stderr)
|
|
10
|
+
server = TestRailMCPServer()
|
|
11
|
+
asyncio.run(server.run_stdio_async())
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
testrail_mcp/config.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Configuration module for TestRail MCP server."""
|
|
2
|
+
import os
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
# Load environment variables from .env file
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
# TestRail configuration
|
|
9
|
+
TESTRAIL_URL = os.getenv('TESTRAIL_URL')
|
|
10
|
+
TESTRAIL_USERNAME = os.getenv('TESTRAIL_USERNAME')
|
|
11
|
+
TESTRAIL_API_KEY = os.getenv('TESTRAIL_API_KEY')
|
|
12
|
+
|
|
13
|
+
# Validate configuration
|
|
14
|
+
if not all([TESTRAIL_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY]):
|
|
15
|
+
raise ValueError(
|
|
16
|
+
"Missing TestRail configuration. Please set TESTRAIL_URL, "
|
|
17
|
+
"TESTRAIL_USERNAME, and TESTRAIL_API_KEY environment variables."
|
|
18
|
+
)
|
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""MCP server implementation for TestRail."""
|
|
2
|
+
from typing import Dict, List, Any, Optional, Union
|
|
3
|
+
from fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
from testrail_mcp.testrail_client import TestRailClient
|
|
6
|
+
from testrail_mcp.config import TESTRAIL_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRailMCPServer(FastMCP):
|
|
10
|
+
"""MCP server for TestRail integration using FastMCP."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
"""Initialize the TestRail MCP server."""
|
|
14
|
+
super().__init__(name="TestRail MCP Server", version="0.1.3")
|
|
15
|
+
self.client = TestRailClient(TESTRAIL_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY)
|
|
16
|
+
self._register_tools()
|
|
17
|
+
self._register_resources()
|
|
18
|
+
|
|
19
|
+
def _register_tools(self):
|
|
20
|
+
"""Register all TestRail tools with the MCP server."""
|
|
21
|
+
# Project tools
|
|
22
|
+
@self.tool("get_project", description="Get a project by ID")
|
|
23
|
+
def get_project(project_id: int) -> Dict:
|
|
24
|
+
"""Get a project by ID."""
|
|
25
|
+
return self.client.get_project(project_id)
|
|
26
|
+
|
|
27
|
+
@self.tool("get_projects", description="Get all projects")
|
|
28
|
+
def get_projects() -> List[Dict]:
|
|
29
|
+
"""Get all projects."""
|
|
30
|
+
return self.client.get_projects()
|
|
31
|
+
|
|
32
|
+
@self.tool("add_project", description="Add a new project")
|
|
33
|
+
def add_project(
|
|
34
|
+
name: str,
|
|
35
|
+
announcement: Optional[str] = None,
|
|
36
|
+
show_announcement: Optional[bool] = None,
|
|
37
|
+
suite_mode: Optional[int] = None
|
|
38
|
+
) -> Dict:
|
|
39
|
+
"""
|
|
40
|
+
Add a new project.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: The name of the project
|
|
44
|
+
announcement: The announcement of the project (optional)
|
|
45
|
+
show_announcement: Whether to show the announcement (optional)
|
|
46
|
+
suite_mode: The suite mode: 1 for single suite mode, 2 for single suite + baselines, 3 for multiple suites (optional)
|
|
47
|
+
"""
|
|
48
|
+
data = {'name': name}
|
|
49
|
+
if announcement is not None:
|
|
50
|
+
data['announcement'] = announcement
|
|
51
|
+
if show_announcement is not None:
|
|
52
|
+
data['show_announcement'] = show_announcement
|
|
53
|
+
if suite_mode is not None:
|
|
54
|
+
data['suite_mode'] = suite_mode
|
|
55
|
+
return self.client.add_project(data)
|
|
56
|
+
|
|
57
|
+
@self.tool("update_project", description="Update an existing project")
|
|
58
|
+
def update_project(
|
|
59
|
+
project_id: int,
|
|
60
|
+
name: Optional[str] = None,
|
|
61
|
+
announcement: Optional[str] = None,
|
|
62
|
+
show_announcement: Optional[bool] = None,
|
|
63
|
+
is_completed: Optional[bool] = None
|
|
64
|
+
) -> Dict:
|
|
65
|
+
"""
|
|
66
|
+
Update an existing project.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
project_id: The ID of the project
|
|
70
|
+
name: The name of the project (optional)
|
|
71
|
+
announcement: The announcement of the project (optional)
|
|
72
|
+
show_announcement: Whether to show the announcement (optional)
|
|
73
|
+
is_completed: Whether the project is completed (optional)
|
|
74
|
+
"""
|
|
75
|
+
data = {}
|
|
76
|
+
if name is not None:
|
|
77
|
+
data['name'] = name
|
|
78
|
+
if announcement is not None:
|
|
79
|
+
data['announcement'] = announcement
|
|
80
|
+
if show_announcement is not None:
|
|
81
|
+
data['show_announcement'] = show_announcement
|
|
82
|
+
if is_completed is not None:
|
|
83
|
+
data['is_completed'] = is_completed
|
|
84
|
+
return self.client.update_project(project_id, data)
|
|
85
|
+
|
|
86
|
+
@self.tool("delete_project", description="Delete a project")
|
|
87
|
+
def delete_project(project_id: int) -> Dict:
|
|
88
|
+
"""
|
|
89
|
+
Delete a project.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
project_id: The ID of the project
|
|
93
|
+
"""
|
|
94
|
+
return self.client.delete_project(project_id)
|
|
95
|
+
|
|
96
|
+
# Case tools
|
|
97
|
+
@self.tool("get_case", description="Get a test case by ID")
|
|
98
|
+
def get_case(case_id: int) -> Dict:
|
|
99
|
+
"""
|
|
100
|
+
Get a test case by ID.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
case_id: The ID of the test case
|
|
104
|
+
"""
|
|
105
|
+
return self.client.get_case(case_id)
|
|
106
|
+
|
|
107
|
+
@self.tool("get_cases", description="Get all test cases for a project/suite")
|
|
108
|
+
def get_cases(project_id: int, suite_id: Optional[int] = None) -> List[Dict]:
|
|
109
|
+
"""
|
|
110
|
+
Get all test cases for a project/suite.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
project_id: The ID of the project
|
|
114
|
+
suite_id: The ID of the test suite (optional)
|
|
115
|
+
"""
|
|
116
|
+
return self.client.get_cases(project_id, suite_id)
|
|
117
|
+
|
|
118
|
+
@self.tool("add_case", description="Add a new test case")
|
|
119
|
+
def add_case(
|
|
120
|
+
section_id: int,
|
|
121
|
+
title: str,
|
|
122
|
+
type_id: Optional[int] = None,
|
|
123
|
+
priority_id: Optional[int] = None,
|
|
124
|
+
estimate: Optional[str] = None,
|
|
125
|
+
milestone_id: Optional[int] = None,
|
|
126
|
+
refs: Optional[str] = None,
|
|
127
|
+
custom_steps: Optional[str] = None,
|
|
128
|
+
custom_expected: Optional[str] = None,
|
|
129
|
+
custom_steps_separated: Optional[List[Dict[str, str]]] = None,
|
|
130
|
+
steps_separated: Optional[List[Dict[str, str]]] = None
|
|
131
|
+
) -> Dict:
|
|
132
|
+
"""
|
|
133
|
+
Add a new test case.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
section_id: The ID of the section
|
|
137
|
+
title: The title of the test case
|
|
138
|
+
type_id: The ID of the case type (optional)
|
|
139
|
+
priority_id: The ID of the priority (optional)
|
|
140
|
+
estimate: The estimate, e.g. '30s' or '1m 45s' (optional)
|
|
141
|
+
milestone_id: The ID of the milestone (optional)
|
|
142
|
+
refs: A comma-separated list of references (optional)
|
|
143
|
+
custom_steps: Steps as string
|
|
144
|
+
custom_expected: case expected result
|
|
145
|
+
custom_steps_separated: A list of test steps (optional), each with fields:
|
|
146
|
+
- content: The text contents of the "Step" field
|
|
147
|
+
- expected: The text contents of the "Expected Result" field
|
|
148
|
+
- additional_info: The text contents of the "Additional Info" field
|
|
149
|
+
- refs: Reference information for the "References" field
|
|
150
|
+
steps_separated: A list of test steps (optional), each with fields:
|
|
151
|
+
- content: The text contents of the "Step" field
|
|
152
|
+
- expected: The text contents of the "Expected Result" field
|
|
153
|
+
- additional_info: The text contents of the "Additional Info" field
|
|
154
|
+
- refs: Reference information for the "References" field
|
|
155
|
+
"""
|
|
156
|
+
data = {'title': title}
|
|
157
|
+
if type_id is not None:
|
|
158
|
+
data['type_id'] = type_id
|
|
159
|
+
if priority_id is not None:
|
|
160
|
+
data['priority_id'] = priority_id
|
|
161
|
+
if estimate is not None:
|
|
162
|
+
data['estimate'] = estimate
|
|
163
|
+
if milestone_id is not None:
|
|
164
|
+
data['milestone_id'] = milestone_id
|
|
165
|
+
if refs is not None:
|
|
166
|
+
data['refs'] = refs
|
|
167
|
+
if custom_steps_separated is not None:
|
|
168
|
+
data['custom_steps_separated'] = custom_steps_separated
|
|
169
|
+
if steps_separated is not None:
|
|
170
|
+
data['steps_separated'] = steps_separated
|
|
171
|
+
if custom_steps is not None:
|
|
172
|
+
data['custom_steps'] = custom_steps
|
|
173
|
+
if custom_expected is not None:
|
|
174
|
+
data['custom_expected'] = custom_expected
|
|
175
|
+
return self.client.add_case(section_id, data)
|
|
176
|
+
|
|
177
|
+
@self.tool("update_case", description="Update an existing test case")
|
|
178
|
+
def update_case(
|
|
179
|
+
case_id: int,
|
|
180
|
+
title: Optional[str] = None,
|
|
181
|
+
type_id: Optional[int] = None,
|
|
182
|
+
priority_id: Optional[int] = None,
|
|
183
|
+
estimate: Optional[str] = None,
|
|
184
|
+
milestone_id: Optional[int] = None,
|
|
185
|
+
refs: Optional[str] = None,
|
|
186
|
+
custom_steps: Optional[str] = None,
|
|
187
|
+
custom_expected: Optional[str] = None,
|
|
188
|
+
custom_steps_separated: Optional[List[Dict[str, str]]] = None,
|
|
189
|
+
steps_separated: Optional[List[Dict[str, str]]] = None
|
|
190
|
+
) -> Dict:
|
|
191
|
+
"""
|
|
192
|
+
Update an existing test case.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
case_id: The ID of the test case
|
|
196
|
+
title: The title of the test case (optional)
|
|
197
|
+
type_id: The ID of the case type (optional)
|
|
198
|
+
priority_id: The ID of the priority (optional)
|
|
199
|
+
estimate: The estimate, e.g. '30s' or '1m 45s' (optional)
|
|
200
|
+
milestone_id: The ID of the milestone (optional)
|
|
201
|
+
refs: A comma-separated list of references (optional)
|
|
202
|
+
custom_expected: case expected result
|
|
203
|
+
custom_steps_separated: A list of test steps (optional), each with fields:
|
|
204
|
+
- content: The text contents of the "Step" field
|
|
205
|
+
- expected: The text contents of the "Expected Result" field
|
|
206
|
+
- additional_info: The text contents of the "Additional Info" field
|
|
207
|
+
- refs: Reference information for the "References" field
|
|
208
|
+
steps_separated: A list of test steps (optional), each with fields:
|
|
209
|
+
- content: The text contents of the "Step" field
|
|
210
|
+
- expected: The text contents of the "Expected Result" field
|
|
211
|
+
- additional_info: The text contents of the "Additional Info" field
|
|
212
|
+
- refs: Reference information for the "References" field
|
|
213
|
+
"""
|
|
214
|
+
data = {}
|
|
215
|
+
if title is not None:
|
|
216
|
+
data['title'] = title
|
|
217
|
+
if type_id is not None:
|
|
218
|
+
data['type_id'] = type_id
|
|
219
|
+
if priority_id is not None:
|
|
220
|
+
data['priority_id'] = priority_id
|
|
221
|
+
if estimate is not None:
|
|
222
|
+
data['estimate'] = estimate
|
|
223
|
+
if milestone_id is not None:
|
|
224
|
+
data['milestone_id'] = milestone_id
|
|
225
|
+
if refs is not None:
|
|
226
|
+
data['refs'] = refs
|
|
227
|
+
if custom_steps_separated is not None:
|
|
228
|
+
data['custom_steps_separated'] = custom_steps_separated
|
|
229
|
+
if steps_separated is not None:
|
|
230
|
+
data['steps_separated'] = steps_separated
|
|
231
|
+
if custom_steps is not None:
|
|
232
|
+
data['custom_steps'] = custom_steps
|
|
233
|
+
if custom_expected is not None:
|
|
234
|
+
data['custom_expected'] = custom_expected
|
|
235
|
+
return self.client.update_case(case_id, data)
|
|
236
|
+
|
|
237
|
+
@self.tool("delete_case", description="Delete a test case")
|
|
238
|
+
def delete_case(case_id: int) -> Dict:
|
|
239
|
+
"""
|
|
240
|
+
Delete a test case.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
case_id: The ID of the test case
|
|
244
|
+
"""
|
|
245
|
+
return self.client.delete_case(case_id)
|
|
246
|
+
# Section tools
|
|
247
|
+
@self.tool("get_section", description="Retrieves details of a specific section by ID")
|
|
248
|
+
def get_section(section_id: int) -> Dict:
|
|
249
|
+
"""
|
|
250
|
+
Get a section by ID.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
section_id: The ID of the section
|
|
254
|
+
"""
|
|
255
|
+
return self.client.get_section(section_id)
|
|
256
|
+
|
|
257
|
+
@self.tool("get_sections", description="Retrieves all sections for a specified project and or suite")
|
|
258
|
+
def get_sections(
|
|
259
|
+
project_id : int,
|
|
260
|
+
suite_id: Optional[int] = None ) -> Dict:
|
|
261
|
+
"""
|
|
262
|
+
Retrieves all sections for a specified project and suite
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
project_id: The ID of the project
|
|
266
|
+
suite_id: The ID of the test suite (Optional)
|
|
267
|
+
|
|
268
|
+
"""
|
|
269
|
+
return self.client.get_sections(project_id,suite_id)
|
|
270
|
+
|
|
271
|
+
@self.tool("add_section", description="Creates a new section in a TestRail project")
|
|
272
|
+
def add_section(
|
|
273
|
+
project_id : int,
|
|
274
|
+
name: str,
|
|
275
|
+
description: str,
|
|
276
|
+
suite_id: Optional[int] = None,
|
|
277
|
+
parent_id: Optional[int] = None) -> Dict:
|
|
278
|
+
"""
|
|
279
|
+
Retrieves all sections for a specified project and suite
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
project_id: The ID of the project
|
|
283
|
+
name: Name of the section
|
|
284
|
+
description: Description of the section
|
|
285
|
+
suite_id: The ID of the test suite (Optional)
|
|
286
|
+
parent_id: The ID of the parent
|
|
287
|
+
|
|
288
|
+
"""
|
|
289
|
+
data = {}
|
|
290
|
+
data["name"] = name
|
|
291
|
+
data["description"] = description
|
|
292
|
+
if suite_id is not None:
|
|
293
|
+
data["suite_id"] = suite_id
|
|
294
|
+
if parent_id is not None:
|
|
295
|
+
data["parent_id"] = parent_id
|
|
296
|
+
|
|
297
|
+
return self.client.add_section(project_id,data)
|
|
298
|
+
|
|
299
|
+
@self.tool("update_section", description="Updates an existing section")
|
|
300
|
+
def update_section(
|
|
301
|
+
section_id : int,
|
|
302
|
+
name: Optional[str] = None,
|
|
303
|
+
description: Optional[str] = None) -> Dict:
|
|
304
|
+
"""
|
|
305
|
+
Updates an existing section
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
section_id: The ID of the section
|
|
309
|
+
name: Name of the section
|
|
310
|
+
description: Description of the section
|
|
311
|
+
"""
|
|
312
|
+
data = {}
|
|
313
|
+
if name is not None:
|
|
314
|
+
data["name"] = name
|
|
315
|
+
if description is not None:
|
|
316
|
+
data["description"] = description
|
|
317
|
+
|
|
318
|
+
return self.client.update_section(section_id, data)
|
|
319
|
+
|
|
320
|
+
@self.tool("delete_section", description="Deletes a section")
|
|
321
|
+
def delete_section(
|
|
322
|
+
section_id : int,
|
|
323
|
+
soft: bool) -> Dict:
|
|
324
|
+
"""
|
|
325
|
+
Deletes an existing section
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
section_id: The ID of the section
|
|
329
|
+
soft: Omitting the soft parameter, or submitting soft=0 will delete the section and its test cases If soft=1, this will return data on the number of affected tests, cases, etc.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
return self.client.delete_section(section_id, soft)
|
|
333
|
+
|
|
334
|
+
@self.tool("move_section", description="Moves a section to a new position in the test hierarchy")
|
|
335
|
+
def move_section(
|
|
336
|
+
section_id : int,
|
|
337
|
+
parent_id : Optional[int],
|
|
338
|
+
after_id : Optional[int]) -> Dict:
|
|
339
|
+
"""
|
|
340
|
+
Moves a section to a new position in the test hierarchy
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
section_id: The ID of the section
|
|
344
|
+
parent_id: ID of the new parent
|
|
345
|
+
after_id: ID of the section to be moved after
|
|
346
|
+
"""
|
|
347
|
+
data = {}
|
|
348
|
+
if parent_id is not None:
|
|
349
|
+
data["parent_id"] = parent_id
|
|
350
|
+
if after_id is not None:
|
|
351
|
+
data["after_id"] = after_id
|
|
352
|
+
|
|
353
|
+
return self.client.move_section(section_id, data)
|
|
354
|
+
|
|
355
|
+
# Run tools
|
|
356
|
+
@self.tool("get_run", description="Get a test run by ID")
|
|
357
|
+
def get_run(run_id: int) -> Dict:
|
|
358
|
+
"""
|
|
359
|
+
Get a test run by ID.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
run_id: The ID of the test run
|
|
363
|
+
"""
|
|
364
|
+
return self.client.get_run(run_id)
|
|
365
|
+
|
|
366
|
+
@self.tool("get_runs", description="Get all test runs for a project")
|
|
367
|
+
def get_runs(project_id: int) -> List[Dict]:
|
|
368
|
+
"""
|
|
369
|
+
Get all test runs for a project.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
project_id: The ID of the project
|
|
373
|
+
"""
|
|
374
|
+
return self.client.get_runs(project_id)
|
|
375
|
+
|
|
376
|
+
@self.tool("add_run", description="Add a new test run")
|
|
377
|
+
def add_run(
|
|
378
|
+
project_id: int,
|
|
379
|
+
suite_id: int,
|
|
380
|
+
name: str,
|
|
381
|
+
description: Optional[str] = None,
|
|
382
|
+
milestone_id: Optional[int] = None,
|
|
383
|
+
assignedto_id: Optional[int] = None,
|
|
384
|
+
include_all: Optional[bool] = None,
|
|
385
|
+
case_ids: Optional[List[int]] = None
|
|
386
|
+
) -> Dict:
|
|
387
|
+
"""
|
|
388
|
+
Add a new test run.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
project_id: The ID of the project
|
|
392
|
+
suite_id: The ID of the test suite
|
|
393
|
+
name: The name of the test run
|
|
394
|
+
description: The description of the test run (optional)
|
|
395
|
+
milestone_id: The ID of the milestone (optional)
|
|
396
|
+
assignedto_id: The ID of the user the test run should be assigned to (optional)
|
|
397
|
+
include_all: True for including all test cases of the test suite and false for a custom case selection (default: true) (optional)
|
|
398
|
+
case_ids: An array of case IDs for the custom case selection (optional)
|
|
399
|
+
"""
|
|
400
|
+
data = {
|
|
401
|
+
'suite_id': suite_id,
|
|
402
|
+
'name': name
|
|
403
|
+
}
|
|
404
|
+
if description is not None:
|
|
405
|
+
data['description'] = description
|
|
406
|
+
if milestone_id is not None:
|
|
407
|
+
data['milestone_id'] = milestone_id
|
|
408
|
+
if assignedto_id is not None:
|
|
409
|
+
data['assignedto_id'] = assignedto_id
|
|
410
|
+
if include_all is not None:
|
|
411
|
+
data['include_all'] = include_all
|
|
412
|
+
if case_ids is not None:
|
|
413
|
+
data['case_ids'] = case_ids
|
|
414
|
+
return self.client.add_run(project_id, data)
|
|
415
|
+
|
|
416
|
+
@self.tool("update_run", description="Update an existing test run")
|
|
417
|
+
def update_run(
|
|
418
|
+
run_id: int,
|
|
419
|
+
name: Optional[str] = None,
|
|
420
|
+
description: Optional[str] = None,
|
|
421
|
+
milestone_id: Optional[int] = None,
|
|
422
|
+
assignedto_id: Optional[int] = None,
|
|
423
|
+
include_all: Optional[bool] = None,
|
|
424
|
+
case_ids: Optional[List[int]] = None
|
|
425
|
+
) -> Dict:
|
|
426
|
+
"""
|
|
427
|
+
Update an existing test run.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
run_id: The ID of the test run
|
|
431
|
+
name: The name of the test run (optional)
|
|
432
|
+
description: The description of the test run (optional)
|
|
433
|
+
milestone_id: The ID of the milestone (optional)
|
|
434
|
+
assignedto_id: The ID of the user the test run should be assigned to (optional)
|
|
435
|
+
include_all: True for including all test cases of the test suite and false for a custom case selection (default: true) (optional)
|
|
436
|
+
case_ids: An array of case IDs for the custom case selection (optional)
|
|
437
|
+
"""
|
|
438
|
+
data = {}
|
|
439
|
+
if name is not None:
|
|
440
|
+
data['name'] = name
|
|
441
|
+
if description is not None:
|
|
442
|
+
data['description'] = description
|
|
443
|
+
if milestone_id is not None:
|
|
444
|
+
data['milestone_id'] = milestone_id
|
|
445
|
+
if assignedto_id is not None:
|
|
446
|
+
data['assignedto_id'] = assignedto_id
|
|
447
|
+
if include_all is not None:
|
|
448
|
+
data['include_all'] = include_all
|
|
449
|
+
if case_ids is not None:
|
|
450
|
+
data['case_ids'] = case_ids
|
|
451
|
+
return self.client.update_run(run_id, data)
|
|
452
|
+
|
|
453
|
+
@self.tool("close_run", description="Close an existing test run")
|
|
454
|
+
def close_run(run_id: int) -> Dict:
|
|
455
|
+
"""
|
|
456
|
+
Close an existing test run.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
run_id: The ID of the test run
|
|
460
|
+
"""
|
|
461
|
+
return self.client.close_run(run_id)
|
|
462
|
+
|
|
463
|
+
@self.tool("delete_run", description="Delete a test run")
|
|
464
|
+
def delete_run(run_id: int) -> Dict:
|
|
465
|
+
"""
|
|
466
|
+
Delete a test run.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
run_id: The ID of the test run
|
|
470
|
+
"""
|
|
471
|
+
return self.client.delete_run(run_id)
|
|
472
|
+
|
|
473
|
+
# Results tools
|
|
474
|
+
@self.tool("get_results", description="Get all test results for a test")
|
|
475
|
+
def get_results(test_id: int) -> List[Dict]:
|
|
476
|
+
"""
|
|
477
|
+
Get all test results for a test.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
test_id: The ID of the test
|
|
481
|
+
"""
|
|
482
|
+
return self.client.get_results(test_id)
|
|
483
|
+
|
|
484
|
+
@self.tool("add_result", description="Add a new test result")
|
|
485
|
+
def add_result(
|
|
486
|
+
test_id: int,
|
|
487
|
+
status_id: int,
|
|
488
|
+
comment: Optional[str] = None,
|
|
489
|
+
version: Optional[str] = None,
|
|
490
|
+
elapsed: Optional[str] = None,
|
|
491
|
+
defects: Optional[str] = None,
|
|
492
|
+
assignedto_id: Optional[int] = None
|
|
493
|
+
) -> Dict:
|
|
494
|
+
"""
|
|
495
|
+
Add a new test result.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
test_id: The ID of the test
|
|
499
|
+
status_id: The ID of the test status
|
|
500
|
+
comment: The comment / description for the test result (optional)
|
|
501
|
+
version: The version or build you tested against (optional)
|
|
502
|
+
elapsed: The time it took to execute the test, e.g. '30s' or '1m 45s' (optional)
|
|
503
|
+
defects: A comma-separated list of defects to link to the test result (optional)
|
|
504
|
+
assignedto_id: The ID of a user the test should be assigned to (optional)
|
|
505
|
+
"""
|
|
506
|
+
data = {
|
|
507
|
+
'status_id': status_id
|
|
508
|
+
}
|
|
509
|
+
if comment is not None:
|
|
510
|
+
data['comment'] = comment
|
|
511
|
+
if version is not None:
|
|
512
|
+
data['version'] = version
|
|
513
|
+
if elapsed is not None:
|
|
514
|
+
data['elapsed'] = elapsed
|
|
515
|
+
if defects is not None:
|
|
516
|
+
data['defects'] = defects
|
|
517
|
+
if assignedto_id is not None:
|
|
518
|
+
data['assignedto_id'] = assignedto_id
|
|
519
|
+
return self.client.add_result(test_id, data)
|
|
520
|
+
|
|
521
|
+
# Dataset tools
|
|
522
|
+
@self.tool("get_dataset", description="Get a dataset by ID")
|
|
523
|
+
def get_dataset(dataset_id: int) -> Dict:
|
|
524
|
+
"""
|
|
525
|
+
Get a dataset by ID.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
dataset_id: The ID of the dataset
|
|
529
|
+
"""
|
|
530
|
+
return self.client.get_dataset(dataset_id)
|
|
531
|
+
|
|
532
|
+
@self.tool("get_datasets", description="Get all datasets for a project")
|
|
533
|
+
def get_datasets(project_id: int) -> List[Dict]:
|
|
534
|
+
"""
|
|
535
|
+
Get all datasets for a project.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
project_id: The ID of the project
|
|
539
|
+
"""
|
|
540
|
+
return self.client.get_datasets(project_id)
|
|
541
|
+
|
|
542
|
+
@self.tool("add_dataset", description="Add a new dataset")
|
|
543
|
+
def add_dataset(
|
|
544
|
+
project_id: int,
|
|
545
|
+
name: str,
|
|
546
|
+
description: Optional[str] = None
|
|
547
|
+
) -> Dict:
|
|
548
|
+
"""
|
|
549
|
+
Add a new dataset.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
project_id: The ID of the project
|
|
553
|
+
name: The name of the dataset
|
|
554
|
+
description: The description of the dataset (optional)
|
|
555
|
+
"""
|
|
556
|
+
data = {
|
|
557
|
+
'name': name
|
|
558
|
+
}
|
|
559
|
+
if description is not None:
|
|
560
|
+
data['description'] = description
|
|
561
|
+
return self.client.add_dataset(project_id, data)
|
|
562
|
+
|
|
563
|
+
@self.tool("update_dataset", description="Update an existing dataset")
|
|
564
|
+
def update_dataset(
|
|
565
|
+
dataset_id: int,
|
|
566
|
+
name: Optional[str] = None,
|
|
567
|
+
description: Optional[str] = None
|
|
568
|
+
) -> Dict:
|
|
569
|
+
"""
|
|
570
|
+
Update an existing dataset.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
dataset_id: The ID of the dataset
|
|
574
|
+
name: The name of the dataset (optional)
|
|
575
|
+
description: The description of the dataset (optional)
|
|
576
|
+
"""
|
|
577
|
+
data = {}
|
|
578
|
+
if name is not None:
|
|
579
|
+
data['name'] = name
|
|
580
|
+
if description is not None:
|
|
581
|
+
data['description'] = description
|
|
582
|
+
return self.client.update_dataset(dataset_id, data)
|
|
583
|
+
|
|
584
|
+
@self.tool("delete_dataset", description="Delete a dataset")
|
|
585
|
+
def delete_dataset(dataset_id: int) -> Dict:
|
|
586
|
+
"""
|
|
587
|
+
Delete a dataset.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
dataset_id: The ID of the dataset
|
|
591
|
+
"""
|
|
592
|
+
return self.client.delete_dataset(dataset_id)
|
|
593
|
+
|
|
594
|
+
def _register_resources(self):
|
|
595
|
+
"""Register all TestRail resources with the MCP server."""
|
|
596
|
+
@self.resource("testrail://project/{project_id}")
|
|
597
|
+
def get_project_resource(project_id: int) -> Dict:
|
|
598
|
+
"""
|
|
599
|
+
Get a project by ID.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
project_id: The ID of the project
|
|
603
|
+
"""
|
|
604
|
+
return self.client.get_project(project_id)
|
|
605
|
+
|
|
606
|
+
@self.resource("testrail://case/{case_id}")
|
|
607
|
+
def get_case_resource(case_id: int) -> Dict:
|
|
608
|
+
"""
|
|
609
|
+
Get a test case by ID.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
case_id: The ID of the test case
|
|
613
|
+
"""
|
|
614
|
+
return self.client.get_case(case_id)
|
|
615
|
+
|
|
616
|
+
@self.resource("testrail://run/{run_id}")
|
|
617
|
+
def get_run_resource(run_id: int) -> Dict:
|
|
618
|
+
"""
|
|
619
|
+
Get a test run by ID.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
run_id: The ID of the test run
|
|
623
|
+
"""
|
|
624
|
+
return self.client.get_run(run_id)
|
|
625
|
+
|
|
626
|
+
@self.resource("testrail://results/{test_id}")
|
|
627
|
+
def get_results_resource(test_id: int) -> List[Dict]:
|
|
628
|
+
"""
|
|
629
|
+
Get all test results for a test.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
test_id: The ID of the test
|
|
633
|
+
"""
|
|
634
|
+
return self.client.get_results(test_id)
|
|
635
|
+
|
|
636
|
+
@self.resource("testrail://dataset/{dataset_id}")
|
|
637
|
+
def get_dataset_resource(dataset_id: int) -> Dict:
|
|
638
|
+
"""
|
|
639
|
+
Get a dataset by ID.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
dataset_id: The ID of the dataset
|
|
643
|
+
"""
|
|
644
|
+
return self.client.get_dataset(dataset_id)
|
|
645
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""TestRail API client module."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, List, Any, Optional, Union
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
class TestRailClient:
|
|
8
|
+
"""TestRail API client for interacting with TestRail."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, base_url: str, username: str, api_key: str):
|
|
11
|
+
"""
|
|
12
|
+
Initialize the TestRail API client.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
base_url: The URL of your TestRail instance (e.g., [https://example.testrail.io/)](https://example.testrail.io/))
|
|
16
|
+
username: Your TestRail username/email
|
|
17
|
+
api_key: Your TestRail API key
|
|
18
|
+
"""
|
|
19
|
+
self.username = username
|
|
20
|
+
self.api_key = api_key
|
|
21
|
+
|
|
22
|
+
# Ensure the base URL ends with a slash
|
|
23
|
+
if not base_url.endswith('/'):
|
|
24
|
+
base_url += '/'
|
|
25
|
+
self.base_url = base_url + 'index.php?/api/v2/'
|
|
26
|
+
|
|
27
|
+
# Set up the session with authentication
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
auth = str(
|
|
30
|
+
base64.b64encode(
|
|
31
|
+
bytes(f'{username}:{api_key}', 'utf-8')
|
|
32
|
+
),
|
|
33
|
+
'ascii'
|
|
34
|
+
).strip()
|
|
35
|
+
self.session.headers.update({
|
|
36
|
+
'Authorization': f'Basic {auth}',
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
def _send_request(self, method: str, uri: str, data: Optional[Dict] = None) -> Any:
|
|
41
|
+
"""
|
|
42
|
+
Send a request to the TestRail API.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
method: HTTP method (GET, POST, etc.)
|
|
46
|
+
uri: API endpoint URI
|
|
47
|
+
data: Request data for POST/PUT requests
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Response data from TestRail
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
Exception: If the request fails
|
|
54
|
+
"""
|
|
55
|
+
url = self.base_url + uri
|
|
56
|
+
|
|
57
|
+
if method.upper() == 'GET':
|
|
58
|
+
response = self.session.get(url)
|
|
59
|
+
elif method.upper() == 'POST':
|
|
60
|
+
response = self.session.post(url, data=json.dumps(data) if data else None)
|
|
61
|
+
elif method.upper() == 'PUT':
|
|
62
|
+
response = self.session.put(url, data=json.dumps(data) if data else None)
|
|
63
|
+
elif method.upper() == 'DELETE':
|
|
64
|
+
response = self.session.delete(url)
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
67
|
+
|
|
68
|
+
if response.status_code >= 300:
|
|
69
|
+
try:
|
|
70
|
+
error = response.json()
|
|
71
|
+
except:
|
|
72
|
+
error = response.text
|
|
73
|
+
raise Exception(f"TestRail API returned HTTP {response.status_code}: {error}")
|
|
74
|
+
|
|
75
|
+
return response.json() if response.content else {}
|
|
76
|
+
|
|
77
|
+
# Cases API
|
|
78
|
+
def get_case(self, case_id: int) -> Dict:
|
|
79
|
+
"""Get a test case by ID."""
|
|
80
|
+
return self._send_request('GET', f'get_case/{case_id}')
|
|
81
|
+
|
|
82
|
+
def get_cases(self, project_id: int, suite_id: Optional[int] = None) -> List[Dict]:
|
|
83
|
+
"""Get all test cases for a project/suite."""
|
|
84
|
+
uri = f'get_cases/{project_id}'
|
|
85
|
+
if suite_id:
|
|
86
|
+
uri += f'&suite_id={suite_id}'
|
|
87
|
+
return self._send_request('GET', uri)
|
|
88
|
+
|
|
89
|
+
def add_case(self, section_id: int, data: Dict) -> Dict:
|
|
90
|
+
"""Add a new test case."""
|
|
91
|
+
return self._send_request('POST', f'add_case/{section_id}', data)
|
|
92
|
+
|
|
93
|
+
def update_case(self, case_id: int, data: Dict) -> Dict:
|
|
94
|
+
"""Update an existing test case."""
|
|
95
|
+
return self._send_request('POST', f'update_case/{case_id}', data)
|
|
96
|
+
|
|
97
|
+
def delete_case(self, case_id: int) -> Dict:
|
|
98
|
+
"""Delete a test case."""
|
|
99
|
+
return self._send_request('POST', f'delete_case/{case_id}')
|
|
100
|
+
|
|
101
|
+
# Projects API
|
|
102
|
+
def get_project(self, project_id: int) -> Dict:
|
|
103
|
+
"""Get a project by ID."""
|
|
104
|
+
return self._send_request('GET', f'get_project/{project_id}')
|
|
105
|
+
|
|
106
|
+
def get_projects(self) -> List[Dict]:
|
|
107
|
+
"""Get all projects."""
|
|
108
|
+
return self._send_request('GET', 'get_projects')
|
|
109
|
+
|
|
110
|
+
def add_project(self, data: Dict) -> Dict:
|
|
111
|
+
"""Add a new project."""
|
|
112
|
+
return self._send_request('POST', 'add_project', data)
|
|
113
|
+
|
|
114
|
+
def update_project(self, project_id: int, data: Dict) -> Dict:
|
|
115
|
+
"""Update an existing project."""
|
|
116
|
+
return self._send_request('POST', f'update_project/{project_id}', data)
|
|
117
|
+
|
|
118
|
+
def delete_project(self, project_id: int) -> Dict:
|
|
119
|
+
"""Delete a project."""
|
|
120
|
+
return self._send_request('POST', f'delete_project/{project_id}')
|
|
121
|
+
|
|
122
|
+
# Runs API
|
|
123
|
+
def get_run(self, run_id: int) -> Dict:
|
|
124
|
+
"""Get a test run by ID."""
|
|
125
|
+
return self._send_request('GET', f'get_run/{run_id}')
|
|
126
|
+
|
|
127
|
+
def get_runs(self, project_id: int) -> List[Dict]:
|
|
128
|
+
"""Get all test runs for a project."""
|
|
129
|
+
return self._send_request('GET', f'get_runs/{project_id}')
|
|
130
|
+
|
|
131
|
+
def add_run(self, project_id: int, data: Dict) -> Dict:
|
|
132
|
+
"""Add a new test run."""
|
|
133
|
+
return self._send_request('POST', f'add_run/{project_id}', data)
|
|
134
|
+
|
|
135
|
+
def update_run(self, run_id: int, data: Dict) -> Dict:
|
|
136
|
+
"""Update an existing test run."""
|
|
137
|
+
return self._send_request('POST', f'update_run/{run_id}', data)
|
|
138
|
+
|
|
139
|
+
def close_run(self, run_id: int) -> Dict:
|
|
140
|
+
"""Close a test run."""
|
|
141
|
+
return self._send_request('POST', f'close_run/{run_id}')
|
|
142
|
+
|
|
143
|
+
def delete_run(self, run_id: int) -> Dict:
|
|
144
|
+
"""Delete a test run."""
|
|
145
|
+
return self._send_request('POST', f'delete_run/{run_id}')
|
|
146
|
+
|
|
147
|
+
# Results API
|
|
148
|
+
def get_results(self, test_id: int) -> List[Dict]:
|
|
149
|
+
"""Get all results for a test."""
|
|
150
|
+
return self._send_request('GET', f'get_results/{test_id}')
|
|
151
|
+
|
|
152
|
+
def get_results_for_run(self, run_id: int) -> List[Dict]:
|
|
153
|
+
"""Get all results for a run."""
|
|
154
|
+
return self._send_request('GET', f'get_results_for_run/{run_id}')
|
|
155
|
+
|
|
156
|
+
def add_result(self, test_id: int, data: Dict) -> Dict:
|
|
157
|
+
"""Add a new result for a test."""
|
|
158
|
+
return self._send_request('POST', f'add_result/{test_id}', data)
|
|
159
|
+
|
|
160
|
+
def add_results(self, run_id: int, data: Dict) -> List[Dict]:
|
|
161
|
+
"""Add multiple results for a run."""
|
|
162
|
+
return self._send_request('POST', f'add_results/{run_id}', data)
|
|
163
|
+
|
|
164
|
+
def add_results_for_cases(self, run_id: int, data: Dict) -> List[Dict]:
|
|
165
|
+
"""Add results for specific cases in a run."""
|
|
166
|
+
return self._send_request('POST', f'add_results_for_cases/{run_id}', data)
|
|
167
|
+
|
|
168
|
+
# Datasets API (assuming TestRail has dataset endpoints)
|
|
169
|
+
def get_datasets(self, project_id: int) -> List[Dict]:
|
|
170
|
+
"""Get all datasets for a project."""
|
|
171
|
+
return self._send_request('GET', f'get_datasets/{project_id}')
|
|
172
|
+
|
|
173
|
+
def get_dataset(self, dataset_id: int) -> Dict:
|
|
174
|
+
"""Get a dataset by ID."""
|
|
175
|
+
return self._send_request('GET', f'get_dataset/{dataset_id}')
|
|
176
|
+
|
|
177
|
+
def add_dataset(self, project_id: int, data: Dict) -> Dict:
|
|
178
|
+
"""Add a new dataset."""
|
|
179
|
+
return self._send_request('POST', f'add_dataset/{project_id}', data)
|
|
180
|
+
|
|
181
|
+
def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
|
|
182
|
+
"""Update an existing dataset."""
|
|
183
|
+
return self._send_request('POST', f'update_dataset/{dataset_id}', data)
|
|
184
|
+
|
|
185
|
+
def delete_dataset(self, dataset_id: int) -> Dict:
|
|
186
|
+
"""Delete a dataset."""
|
|
187
|
+
return self._send_request('POST', f'delete_dataset/{dataset_id}')
|
|
188
|
+
|
|
189
|
+
# Sections API
|
|
190
|
+
def get_section(self, section_id:int) -> Dict:
|
|
191
|
+
"""Get a specific section"""
|
|
192
|
+
return self._send_request('GET', f'get_section/{section_id}')
|
|
193
|
+
|
|
194
|
+
def get_sections(self, project_id: int, suite_id:Optional[int] = None, params:Optional[Dict] = None) -> Dict:
|
|
195
|
+
"""Get all sections for a project"""
|
|
196
|
+
query_params = {**params, "suite_id": suite_id} if suite_id else params
|
|
197
|
+
return self._send_request('GET', f'get_sections/{project_id}',{params : query_params})
|
|
198
|
+
|
|
199
|
+
def add_section(self, project_id:int, data:Dict) -> Dict:
|
|
200
|
+
"""Add a new section"""
|
|
201
|
+
return self._send_request('POST', f'add_section/{project_id}', data)
|
|
202
|
+
|
|
203
|
+
def update_section(self, section_id:int, data:Dict) -> Dict:
|
|
204
|
+
"""Update an existing section"""
|
|
205
|
+
return self._send_request('POST', f'update_section/{section_id}', data)
|
|
206
|
+
|
|
207
|
+
def delete_section(self, section_id:int, soft:bool) -> Dict:
|
|
208
|
+
"""Delete an existing section"""
|
|
209
|
+
url = f'delete_section/{section_id}'
|
|
210
|
+
if (soft):
|
|
211
|
+
url = f'delete_section/{section_id}?soft=1'
|
|
212
|
+
return self._send_request('POST', url)
|
|
213
|
+
|
|
214
|
+
def move_section(self, section_id:int, data: Dict) -> Dict:
|
|
215
|
+
"""Move a section to a different parent or position"""
|
|
216
|
+
return self._send_request('POST', f'move_section/{section_id}', data)
|