This is page 1 of 4. Use http://codebase.md/infinitiq-tech/mcp-jira?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .github
│ ├── copilot-instructions.md
│ └── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ ├── copilot.yml
│ ├── story.yml
│ └── task.yml
├── .gitignore
├── .lock
├── bin
│ ├── activate
│ ├── activate.csh
│ ├── activate.fish
│ ├── Activate.ps1
│ ├── dotenv
│ ├── httpx
│ ├── jirashell
│ ├── mcp
│ ├── mcp-server-jira
│ ├── normalizer
│ ├── pip
│ ├── pip3
│ ├── pip3.10
│ ├── python
│ ├── python3
│ ├── python3.10
│ └── uvicorn
├── claude_reference
│ ├── jira_advanced.txt
│ ├── jira_api_documentation.txt
│ ├── jira_examples.txt
│ ├── jira_installation.md
│ └── jira_shell.txt
├── Dockerfile
├── docs
│ └── create_issues_v3_conversion.md
├── glama.json
├── jira.py
├── LICENSE
├── mcp
│ ├── __init__.py
│ ├── server
│ │ └── __init__.py
│ └── types
│ └── __init__.py
├── MCPReadme.md
├── pyproject.toml
├── pytest.ini
├── pyvenv.cfg
├── README.md
├── run_server.py
├── src
│ ├── __init__.py
│ ├── mcp
│ │ ├── __init__.py
│ │ ├── server
│ │ │ ├── __init__.py
│ │ │ └── stdio.py
│ │ └── types
│ │ └── __init__.py
│ └── mcp_server_jira
│ ├── __init__.py
│ ├── __main__.py
│ ├── jira_v3_api.py
│ └── server.py
├── tests
│ ├── __init__.py
│ ├── test_add_comment_v3_api_only.py
│ ├── test_bulk_create_issues_v3_api.py
│ ├── test_create_issue_v3_api_only.py
│ ├── test_create_issues_integration.py
│ ├── test_create_jira_issues_server.py
│ ├── test_get_transitions_v3.py
│ ├── test_jira_v3_api.py
│ ├── test_search_issues_v3_api.py
│ ├── test_server.py
│ └── test_transition_issue_v3_api_only.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.lock:
--------------------------------------------------------------------------------
```
1 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | build/
8 | develop-eggs/
9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 |
23 | # Environment variables and secrets
24 | .env
25 | .venv
26 | env/
27 | venv/
28 | ENV/
29 | env.bak/
30 | venv.bak/
31 | *.env
32 | !.env.example
33 |
34 | # IDE files
35 | .idea/
36 | .vscode/
37 | *.swp
38 | *.swo
39 | *~
40 |
41 | # Logs
42 | logs
43 | *.log
44 |
45 | # OS specific
46 | .DS_Store
47 | Thumbs.db
48 |
49 | # Test files
50 | test_*.py
51 | !tests/test_*.py
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # Jira MCP Server - Example Environment File
2 | # Rename this file to .env and update with your Jira credentials
3 |
4 | # Required - URL of your Jira instance
5 | JIRA_SERVER_URL=https://your-instance.atlassian.net
6 |
7 | # Authentication method ('basic_auth' or 'token_auth')
8 | JIRA_AUTH_METHOD=basic_auth
9 |
10 | # For Jira Cloud (basic_auth with API token)
11 | [email protected]
12 | JIRA_TOKEN=your_api_token
13 |
14 | # For Jira Server/Data Center (basic_auth with password)
15 | # JIRA_USERNAME=your_username
16 | # JIRA_PASSWORD=your_password
17 |
18 | # For Jira Server/Data Center (token_auth with PAT)
19 | # JIRA_AUTH_METHOD=token_auth
20 | # JIRA_TOKEN=your_personal_access_token
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Jira MCP Server
2 |
3 | A Model Context Protocol (MCP) server for interacting with Jira's REST API using the `jira-python` library. This server integrates with Claude Desktop and other MCP clients, allowing you to interact with Jira using natural language commands.
4 |
5 | <a href="https://glama.ai/mcp/servers/@InfinitIQ-Tech/mcp-jira">
6 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@InfinitIQ-Tech/mcp-jira/badge" alt="Jira Server MCP server" />
7 | </a>
8 |
9 | ## Features
10 |
11 | - Get all accessible Jira projects
12 | - Get details for a specific Jira issue
13 | - Search issues using JQL (Jira Query Language)
14 | - Create new Jira issues
15 | - Add comments to issues
16 | - Get available transitions for an issue
17 | - Transition issues to new statuses
18 |
19 | ## Installation
20 |
21 | ### Prerequisites
22 |
23 | - Python 3.9 or higher
24 | - A Jira instance (Cloud, Server, or Data Center)
25 | - [uv](https://github.com/astral-sh/uv) (optional but recommended for dependency management)
26 |
27 | ### Activate a virtual environment (recommended)
28 |
29 | ```bash
30 | # Install a Virtual Environment (VENV) in the mcp server directory
31 | python -m venv .
32 | # Activate the virtual environment
33 | source bin/activate
34 | ```
35 | ### Using uv (recommended)
36 | ```
37 | # Install uv if you don't have it
38 | pip install uv
39 |
40 | # Install the Jira MCP server
41 | uv pip install mcp-server-jira
42 | ```
43 |
44 | ### Using pip
45 |
46 | ```bash
47 | pip install mcp-server-jira
48 | ```
49 |
50 | ## Configuration
51 |
52 | ### Environment Variables
53 |
54 | Configure the server using environment variables:
55 |
56 | - `JIRA_SERVER_URL`: URL of your Jira server
57 | - `JIRA_AUTH_METHOD`: Authentication method ('basic_auth' or 'token_auth')
58 | - `JIRA_USERNAME`: Username for basic auth
59 | - `JIRA_PASSWORD`: Password for basic auth
60 | - `JIRA_TOKEN`: API token or Personal Access Token
61 |
62 | ### Environment File (Local Development)
63 |
64 | You can also create a `.env` file in the root directory with your configuration:
65 |
66 | ```
67 | JIRA_SERVER_URL=https://your-jira-instance.atlassian.net
68 | JIRA_AUTH_METHOD=basic_auth
69 | [email protected]
70 | JIRA_TOKEN=your_api_token
71 | ```
72 |
73 | ## Usage
74 |
75 | ### Command Line
76 |
77 | ```bash
78 | python -m mcp_server_jira
79 | ```
80 |
81 | ### Docker
82 |
83 | ```bash
84 | docker build -t mcp-jira .
85 | docker run --env-file .env -p 8080:8080 mcp-jira
86 | ```
87 |
88 | ## Claude Desktop Integration
89 |
90 | To use this server with Claude Desktop:
91 |
92 | 1. Install the server using one of the methods above
93 | 2. In Claude Desktop:
94 | - Go to Settings -> Developer
95 | - Click Edit Config
96 | - Open the json configuraiton in your editor of choice
97 | - Add the following JSON:
98 | (`NOTE`: The environment variables used are for token Auth and will not work with other authentication methods)
99 | ```json
100 | {
101 | "mcpServers": {
102 | "jira": {
103 | "command": "<PATH TO UV> i.e. /Users/<MYUSERNAME>/.local/bin/uv",
104 | "args": [
105 | "--directory",
106 | "<PATH TO JIRA MCP>",
107 | "run",
108 | "mcp-server-jira"
109 | ],
110 | "env": {
111 | "JIRA_SERVER_URL": "https://<ORG>.atlassian.net/",
112 | "JIRA_AUTH_METHOD": "token_auth",
113 | "JIRA_USERNAME": "<USERNAME>",
114 | "JIRA_TOKEN": "<TOKEN>"
115 | }
116 | }
117 | }
118 | }
119 | ```
120 |
121 | 3. Now you can interact with Jira by asking Claude questions like:
122 | - "Show me all my projects in Jira"
123 | - "Get details for issue PROJECT-123"
124 | - "Create a new bug in the PROJECT with summary 'Fix login issue'"
125 | - "Find all open bugs assigned to me"
126 |
127 | ## Authentication
128 |
129 | The server supports multiple authentication methods:
130 |
131 | ### Basic Authentication
132 |
133 | For Jira Server/Data Center with username and password:
134 |
135 | ```bash
136 | JIRA_SERVER_URL="https://jira.example.com"
137 | JIRA_AUTH_METHOD="basic_auth"
138 | JIRA_USERNAME="your_username"
139 | JIRA_PASSWORD="your_password"
140 | ```
141 |
142 | ### API Token (Jira Cloud)
143 |
144 | For Jira Cloud using an API token:
145 |
146 | ```bash
147 | JIRA_SERVER_URL="https://your-domain.atlassian.net"
148 | JIRA_AUTH_METHOD="basic_auth"
149 | JIRA_USERNAME="[email protected]"
150 | JIRA_TOKEN="your_api_token"
151 | ```
152 |
153 | ### Personal Access Token (Jira Server/Data Center)
154 |
155 | For Jira Server/Data Center (8.14+) using a PAT:
156 |
157 | ```bash
158 | JIRA_SERVER_URL="https://jira.example.com"
159 | JIRA_AUTH_METHOD="token_auth"
160 | JIRA_TOKEN="your_personal_access_token"
161 | ```
162 |
163 | ## Available Tools
164 |
165 | 1. `get_projects`: Get all accessible Jira projects
166 | 2. `get_issue`: Get details for a specific Jira issue by key
167 | 3. `search_issues`: Search for Jira issues using JQL
168 | 4. `create_issue`: Create a new Jira issue
169 | 5. `add_comment`: Add a comment to a Jira issue
170 | 6. `get_transitions`: Get available workflow transitions for a Jira issue
171 | 7. `transition_issue`: Transition a Jira issue to a new status
172 |
173 | ## License
174 | MIT
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 | # Test package initialization
2 |
```
--------------------------------------------------------------------------------
/src/mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | # Stub package for mcp modules
2 |
```
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
```python
1 | # namespace package for CLI server code
2 |
```
--------------------------------------------------------------------------------
/mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub package for mcp to satisfy imports in server module.
3 | """
4 |
```
--------------------------------------------------------------------------------
/src/mcp_server_jira/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | MCP Server for Jira API integration.
3 | """
4 |
5 | __version__ = "0.1.0"
6 |
```
--------------------------------------------------------------------------------
/glama.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://glama.ai/mcp/schemas/server.json",
3 | "maintainers": [
4 | "froggomad"
5 | ]
6 | }
7 |
```
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
```
1 | [pytest]
2 | # Ignore local library packages to avoid import conflicts
3 | addopts = --ignore=lib
4 | # Only look for tests in the tests directory
5 | testpaths = tests
6 | norecursedirs = lib
7 |
```
--------------------------------------------------------------------------------
/src/mcp/server/stdio.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub stdio_server for mcp.server.stdio
3 | """
4 |
5 | def stdio_server(*args, **kwargs):
6 | """
7 | Stub implementation of the stdio_server function for mcp.server.stdio.
8 |
9 | This function accepts any arguments but performs no operations and always returns None.
10 | """
11 | return None
12 |
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
```yaml
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 📚 Documentation
4 | url: https://github.com/InfinitIQ-Tech/mcp-jira/blob/main/README.md
5 | about: Check the README for setup and usage instructions
6 | - name: 💬 Discussions
7 | url: https://github.com/InfinitIQ-Tech/mcp-jira/discussions
8 | about: Ask questions and discuss ideas with the community
```
--------------------------------------------------------------------------------
/jira.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub module for jira.JIRA to satisfy imports in server.
3 | """
4 |
5 | class JIRA:
6 | """Stub Jira client class"""
7 | def __init__(self, *args, **kwargs):
8 | # Stub initializer
9 | """
10 | Initializes the JIRA stub instance without performing any setup.
11 |
12 | Accepts any positional or keyword arguments for compatibility with code expecting a JIRA client.
13 | """
14 | pass
15 |
```
--------------------------------------------------------------------------------
/src/mcp_server_jira/__main__.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 | import os
3 |
4 | from .server import serve
5 |
6 |
7 | def main() -> None:
8 | # Get configuration from environment variables
9 | server_url = os.environ.get("JIRA_SERVER_URL")
10 | auth_method = os.environ.get("JIRA_AUTH_METHOD")
11 | username = os.environ.get("JIRA_USERNAME")
12 | password = os.environ.get("JIRA_PASSWORD")
13 | token = os.environ.get("JIRA_TOKEN")
14 |
15 | asyncio.run(
16 | serve(
17 | server_url=server_url,
18 | auth_method=auth_method,
19 | username=username,
20 | password=password,
21 | token=token,
22 | )
23 | )
24 |
25 |
26 | if __name__ == "__main__":
27 | main()
28 |
```
--------------------------------------------------------------------------------
/src/mcp/types/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub package for mcp.types to satisfy imports.
3 | """
4 |
5 | from typing import Any, Dict, Sequence, Union
6 |
7 | class TextContent:
8 | def __init__(self, type: str, text: str):
9 | """
10 | Initializes a TextContent instance with a specified type and text.
11 |
12 | Args:
13 | type: The type of the text content.
14 | text: The textual content.
15 | """
16 | self.type = type
17 | self.text = text
18 |
19 | class ImageContent:
20 | pass
21 |
22 | class EmbeddedResource:
23 | pass
24 |
25 | class Tool:
26 | def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
27 | """
28 | Initializes a Tool instance with a name, description, and input schema.
29 |
30 | Args:
31 | name: The name of the tool.
32 | description: A brief description of the tool.
33 | inputSchema: A dictionary defining the expected input schema for the tool.
34 | """
35 | self.name = name
36 | self.description = description
37 | self.inputSchema = inputSchema
38 |
```
--------------------------------------------------------------------------------
/mcp/types/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub package for mcp.types to satisfy imports in server module.
3 | """
4 | from typing import Any, Dict
5 |
6 | class TextContent:
7 | def __init__(self, type: str, text: str):
8 | """
9 | Initializes a TextContent instance with a specified type and text.
10 |
11 | Args:
12 | type: The type of the text content.
13 | text: The textual content.
14 | """
15 | self.type = type
16 | self.text = text
17 |
18 | class ImageContent:
19 | pass
20 |
21 | class EmbeddedResource:
22 | pass
23 |
24 | class Tool:
25 | def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
26 | """
27 | Initializes a Tool instance with a name, description, and input schema.
28 |
29 | Args:
30 | name: The name of the tool.
31 | description: A brief description of the tool's purpose.
32 | inputSchema: A dictionary defining the expected input schema for the tool.
33 | """
34 | self.name = name
35 | self.description = description
36 | self.inputSchema = inputSchema
37 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM python:3.10-slim
2 |
3 | WORKDIR /app
4 |
5 | # Install uv for dependency management
6 | # Note: In production environments, proper SSL certificates should be used
7 | # The --trusted-host flags are included to handle CI/CD environments with certificate issues
8 | RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir uv
9 |
10 | # Copy pyproject.toml and uv.lock (if it exists)
11 | COPY pyproject.toml uv.lock* ./
12 |
13 | # Copy source code and README (required for package build)
14 | COPY src/ ./src/
15 | COPY README.md ./
16 |
17 | # Create and use a virtual environment with uv
18 | # uv provides isolated virtual environments for MCP servers, preventing conflicts with global Python environment
19 | RUN uv venv /opt/venv && \
20 | VIRTUAL_ENV=/opt/venv uv pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir -e .
21 |
22 | # Activate the virtual environment for all subsequent commands
23 | ENV PATH="/opt/venv/bin:$PATH"
24 |
25 | # Set the entrypoint
26 | ENTRYPOINT ["python", "-m", "mcp_server_jira"]
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "mcp-server-jira"
7 | description = "MCP server for Jira API integration"
8 | readme = "README.md"
9 | requires-python = ">=3.10"
10 | license = {text = "MIT"}
11 | version = "0.1.0"
12 | dependencies = [
13 | "jira>=3.5.0",
14 | "mcp[cli]>=1.0.0",
15 | "pydantic>=2.0.0",
16 | "python-dotenv>=1.0.0",
17 | "httpx>=0.24.0",
18 | ]
19 |
20 | [project.scripts]
21 | mcp-server-jira = "mcp_server_jira.__main__:main"
22 |
23 | [project.optional-dependencies]
24 | dev = [
25 | "pytest",
26 | "black",
27 | "isort",
28 | "mypy",
29 | "uv",
30 | ]
31 |
32 | [tool.black]
33 | line-length = 88
34 | target-version = ["py310"]
35 |
36 | [tool.isort]
37 | profile = "black"
38 | line_length = 88
39 |
40 | [tool.pytest]
41 | testpaths = ["tests"]
42 | python_files = "test_*.py"
43 |
44 | [tool.mypy]
45 | python_version = "3.10"
46 | warn_return_any = true
47 | warn_unused_configs = true
48 | disallow_untyped_defs = true
49 | disallow_incomplete_defs = true
50 |
51 | [[tool.mypy.overrides]]
52 | module = "tests.*"
53 | disallow_untyped_defs = false
54 | disallow_incomplete_defs = false
55 |
56 | [dependency-groups]
57 | dev = [
58 | "pytest>=8.3.5",
59 | "pytest-asyncio>=1.0.0",
60 | ]
61 |
62 | # Remove uv-specific config
```
--------------------------------------------------------------------------------
/mcp/server/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub package for mcp.server to satisfy imports in top-level server module.
3 | """
4 | class Server:
5 | def __init__(self, *args, **kwargs):
6 | """
7 | Initializes the Server stub with arbitrary arguments.
8 |
9 | This constructor accepts any arguments but performs no initialization.
10 | """
11 | pass
12 |
13 | def list_tools(self):
14 | """
15 | Returns a decorator that leaves the decorated function unchanged.
16 |
17 | This method is a stub and does not modify or register the function in any way.
18 | """
19 | def decorator(fn): return fn
20 | return decorator
21 |
22 | def call_tool(self):
23 | """
24 | Returns a decorator that leaves the decorated function unchanged.
25 |
26 | Use this as a placeholder for tool registration in stub implementations.
27 | """
28 | def decorator(fn): return fn
29 | return decorator
30 |
31 | def create_initialization_options(self):
32 | """
33 | Returns an empty dictionary representing initialization options.
34 | """
35 | return {}
36 |
37 | async def run(self, *args, **kwargs):
38 | """
39 | Placeholder asynchronous run method with no implementation.
40 | """
41 | pass
42 |
```
--------------------------------------------------------------------------------
/run_server.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Simple script to run the Jira MCP server with detailed logging
4 | """
5 | import asyncio
6 | import logging
7 | import os
8 | import sys
9 |
10 | from src.mcp_server_jira.server import serve
11 |
12 | # Set up logging
13 | logging.basicConfig(
14 | level=logging.INFO,
15 | format='%(asctime)s [%(levelname)s] %(message)s',
16 | handlers=[logging.StreamHandler(sys.stdout)]
17 | )
18 |
19 | logger = logging.getLogger()
20 |
21 | async def main():
22 | logger.info("Starting Jira MCP server...")
23 |
24 | # Get configuration from environment variables
25 | server_url = os.environ.get("JIRA_SERVER_URL")
26 | auth_method = os.environ.get("JIRA_AUTH_METHOD")
27 | username = os.environ.get("JIRA_USERNAME")
28 | password = os.environ.get("JIRA_PASSWORD")
29 | token = os.environ.get("JIRA_TOKEN")
30 |
31 | logger.info(f"Server URL: {server_url or 'Not configured'}")
32 | logger.info(f"Auth Method: {auth_method or 'Not configured'}")
33 |
34 | try:
35 | await serve(
36 | server_url=server_url,
37 | auth_method=auth_method,
38 | username=username,
39 | password=password,
40 | token=token
41 | )
42 | except Exception as e:
43 | logger.error(f"Error running server: {e}")
44 | raise
45 |
46 | if __name__ == "__main__":
47 | asyncio.run(main())
```
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
```markdown
1 | This is a python based repository using the jira pip package via the Model Context Protocol. This enables users utilizing MCP-enabled LLMS to work with jira in natural language rather than learning the API themselves.
2 |
3 | NOTE: There is an ongoing effort to convert from the jira pip package to utilizing the V3 REST API directly.
4 |
5 | ## Code Standards
6 |
7 | ### Development Flow
8 | - Build/Run: to ensure compatibilty with the end-user, use `uv` as this is what we recommended.
9 | - `uv run mcp-server-jira`
10 | - Test: to ensure functionality, create and run unit tests using pytest
11 |
12 | ## Key Guidelines
13 | 1. Think like "Uncle Bob" (Robert Martin)
14 | 2. Write clean, modular code that follows the Single Responsibility Principle
15 | 3. Ensure best-practices with MCP servers are followed by referring to `/MCPReadme.md`
16 | 4. Ensure instructions to agents are updated via `@server.list_tools()` - this follows a specific schema so don't be creative with keys.
17 | 5. Maintain existing code structure and organization unless otherwise directed
18 | 6. Use dependency injection patterns where appropriate
19 | 7. Write unit tests for new functionality.
20 | 8. Document public APIs and complex logic. Suggest changes to the `docs/` folder when appropriate.
21 | 9. Use black, isort, and mypy for code quality
22 |
```
--------------------------------------------------------------------------------
/src/mcp/server/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Stub package for mcp.server to satisfy imports.
3 | """
4 |
5 | class Server:
6 | """Stubbed Server class"""
7 | def __init__(self, *args, **kwargs):
8 | """
9 | Initializes the Server stub with arbitrary arguments.
10 |
11 | This constructor accepts any positional or keyword arguments but does not perform any initialization logic.
12 | """
13 | pass
14 |
15 | def list_tools(self):
16 | """
17 | Returns a decorator that leaves the decorated function unchanged.
18 |
19 | This is a placeholder implementation with no operational effect.
20 | """
21 | def decorator(fn):
22 | return fn
23 | return decorator
24 |
25 | def call_tool(self):
26 | """
27 | Returns a decorator that leaves the decorated function unchanged.
28 |
29 | This is a placeholder implementation with no operational effect.
30 | """
31 | def decorator(fn):
32 | return fn
33 | return decorator
34 |
35 | def create_initialization_options(self):
36 | """
37 | Returns an empty dictionary representing initialization options.
38 |
39 | This stub method provides a placeholder for initialization configuration.
40 | """
41 | return {}
42 |
43 | async def run(self, *args, **kwargs):
44 | """
45 | Placeholder asynchronous method for running the server.
46 |
47 | This stub implementation does not perform any actions.
48 | """
49 | pass
50 |
```
--------------------------------------------------------------------------------
/claude_reference/jira_installation.md:
--------------------------------------------------------------------------------
```markdown
1 | 1. Installation
2 | The easiest (and best) way to install jira-python is through pip:
3 |
4 | pip install 'jira[cli]'
5 | This will handle installation of the client itself as well as the requirements. The [cli] part installs dependencies for the jirashell binary, and may be omitted if you just need the library.
6 |
7 | If you’re going to run the client standalone, we strongly recommend using a virtualenv:
8 |
9 | python -m venv jira_python
10 | source jira_python/bin/activate
11 | pip install 'jira[cli]'
12 | or:
13 |
14 | python -m venv jira_python
15 | jira_python/bin/pip install 'jira[cli]'
16 | Doing this creates a private Python “installation” that you can freely upgrade, degrade or break without putting the critical components of your system at risk.
17 |
18 | Source packages are also available at PyPI:
19 |
20 | https://pypi.python.org/pypi/jira/
21 |
22 | 1.1. Dependencies
23 | Python >=3.9 is required.
24 |
25 | requests - python-requests library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work.
26 |
27 | requests-oauthlib - Used to implement OAuth. The latest version as of this writing is 1.3.0.
28 |
29 | requests-kerberos - Used to implement Kerberos.
30 |
31 | ipython - The IPython enhanced Python interpreter provides the fancy chrome used by Headers.
32 |
33 | filemagic - This library handles content-type autodetection for things like image uploads. This will only work on a system that provides libmagic; Mac and Unix will almost always have it preinstalled, but Windows users will have to use Cygwin or compile it natively. If your system doesn’t have libmagic, you’ll have to manually specify the contentType parameter on methods that take an image object, such as project and user avatar creation.
34 |
35 | Installing through pip takes care of these dependencies for you.
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Bug Report
2 | description: Report a bug to help us improve
3 | title: "[Bug]: "
4 | labels: ["bug"]
5 | assignees: []
6 | body:
7 | - type: markdown
8 | attributes:
9 | value: |
10 | Thanks for taking the time to fill out this bug report!
11 | - type: input
12 | id: summary
13 | attributes:
14 | label: Summary
15 | description: A brief summary of the bug
16 | placeholder: Describe the bug in one sentence
17 | validations:
18 | required: true
19 | - type: textarea
20 | id: expected-behavior
21 | attributes:
22 | label: Expected Behavior
23 | description: What did you expect to happen?
24 | placeholder: Describe what you expected to happen
25 | validations:
26 | required: true
27 | - type: textarea
28 | id: actual-behavior
29 | attributes:
30 | label: Actual Behavior
31 | description: What actually happened instead?
32 | placeholder: Describe what actually happened
33 | validations:
34 | required: true
35 | - type: textarea
36 | id: reproduction-steps
37 | attributes:
38 | label: Steps to Reproduce
39 | description: Please provide detailed steps to reproduce the issue
40 | placeholder: |
41 | 1. Go to '...'
42 | 2. Click on '....'
43 | 3. Scroll down to '....'
44 | 4. See error
45 | validations:
46 | required: true
47 | - type: textarea
48 | id: environment
49 | attributes:
50 | label: Environment Information
51 | description: Please provide details about your environment
52 | placeholder: |
53 | - OS: [e.g. Windows 10, macOS 11.0, Ubuntu 20.04]
54 | - Python Version: [e.g. 3.10.0]
55 | - MCP Server Jira Version: [e.g. 0.1.0]
56 | - Jira Version: [e.g. Cloud, Server 8.x]
57 | validations:
58 | required: false
59 | - type: textarea
60 | id: additional-context
61 | attributes:
62 | label: Additional Context
63 | description: Add any other context about the problem here, including logs, screenshots, etc.
64 | placeholder: Any additional information that might be helpful
65 | validations:
66 | required: false
```
--------------------------------------------------------------------------------
/claude_reference/jira_advanced.txt:
--------------------------------------------------------------------------------
```
1 | 4. Advanced
2 | 4.1. Resource Objects and Properties
3 | The library distinguishes between two kinds of data in the Jira REST API: resources and properties.
4 |
5 | A resource is a REST entity that represents the current state of something that the server owns; for example, the issue called “ABC-123” is a concept managed by Jira which can be viewed as a resource obtainable at the URL http://jira-server/rest/api/latest/issue/ABC-123. All resources have a self link: a root-level property called self which contains the URL the resource originated from. In jira-python, resources are instances of the Resource object (or one of its subclasses) and can only be obtained from the server using the find() method. Resources may be connected to other resources: the issue Resource is connected to a user Resource through the assignee and reporter fields, while the project Resource is connected to a project lead through another user Resource.
6 |
7 | Important
8 |
9 | A resource is connected to other resources, and the client preserves this connection. In the above example, the object inside the issue object at issue.fields.assignee is not just a dict – it is a full-fledged user Resource object. Whenever a resource contains other resources, the client will attempt to convert them to the proper subclass of Resource.
10 |
11 | A properties object is a collection of values returned by Jira in response to some query from the REST API. Their structure is freeform and modeled as a Python dict. Client methods return this structure for calls that do not produce resources. For example, the properties returned from the URL http://jira-server/rest/api/latest/issue/createmeta are designed to inform users what fields (and what values for those fields) are required to successfully create issues in the server’s projects. Since these properties are determined by Jira’s configuration, they are not resources.
12 |
13 | The Jira client’s methods document whether they will return a Resource or a properties object.
```
--------------------------------------------------------------------------------
/bin/activate.fish:
--------------------------------------------------------------------------------
```
1 | # This file must be used with "source <venv>/bin/activate.fish" *from fish*
2 | # (https://fishshell.com/); you cannot run it directly.
3 |
4 | function deactivate -d "Exit virtual environment and return to normal shell environment"
5 | # reset old environment variables
6 | if test -n "$_OLD_VIRTUAL_PATH"
7 | set -gx PATH $_OLD_VIRTUAL_PATH
8 | set -e _OLD_VIRTUAL_PATH
9 | end
10 | if test -n "$_OLD_VIRTUAL_PYTHONHOME"
11 | set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
12 | set -e _OLD_VIRTUAL_PYTHONHOME
13 | end
14 |
15 | if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
16 | set -e _OLD_FISH_PROMPT_OVERRIDE
17 | # prevents error when using nested fish instances (Issue #93858)
18 | if functions -q _old_fish_prompt
19 | functions -e fish_prompt
20 | functions -c _old_fish_prompt fish_prompt
21 | functions -e _old_fish_prompt
22 | end
23 | end
24 |
25 | set -e VIRTUAL_ENV
26 | set -e VIRTUAL_ENV_PROMPT
27 | if test "$argv[1]" != "nondestructive"
28 | # Self-destruct!
29 | functions -e deactivate
30 | end
31 | end
32 |
33 | # Unset irrelevant variables.
34 | deactivate nondestructive
35 |
36 | set -gx VIRTUAL_ENV "/Users/kennethdubroff/Development/AI/Vibe/Claude 3.7/claudemcp/servers-main/src/jira"
37 |
38 | set -gx _OLD_VIRTUAL_PATH $PATH
39 | set -gx PATH "$VIRTUAL_ENV/bin" $PATH
40 |
41 | # Unset PYTHONHOME if set.
42 | if set -q PYTHONHOME
43 | set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
44 | set -e PYTHONHOME
45 | end
46 |
47 | if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
48 | # fish uses a function instead of an env var to generate the prompt.
49 |
50 | # Save the current fish_prompt function as the function _old_fish_prompt.
51 | functions -c fish_prompt _old_fish_prompt
52 |
53 | # With the original prompt function renamed, we can override with our own.
54 | function fish_prompt
55 | # Save the return status of the last command.
56 | set -l old_status $status
57 |
58 | # Output the venv prompt; color taken from the blue of the Python logo.
59 | printf "%s%s%s" (set_color 4B8BBE) "(jira) " (set_color normal)
60 |
61 | # Restore the return status of the previous command.
62 | echo "exit $old_status" | .
63 | # Output the original/"old" prompt.
64 | _old_fish_prompt
65 | end
66 |
67 | set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
68 | set -gx VIRTUAL_ENV_PROMPT "(jira) "
69 | end
70 |
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/story.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: User Story
2 | description: Create a user story for new functionality
3 | title: "[Story]: "
4 | labels: ["story", "enhancement"]
5 | assignees: []
6 | body:
7 | - type: markdown
8 | attributes:
9 | value: |
10 | Thanks for creating a user story! Please fill out the sections below to help us understand your requirements.
11 | - type: textarea
12 | id: user-story
13 | attributes:
14 | label: User Story
15 | description: Write your user story in the format "As a [user type], I want [goal] so that [benefit]"
16 | placeholder: |
17 | As a [user type],
18 | I want [goal],
19 | so that [benefit].
20 | validations:
21 | required: true
22 | - type: textarea
23 | id: background
24 | attributes:
25 | label: Background
26 | description: Provide context and background information for this story
27 | placeholder: |
28 | Describe the background, context, and any relevant information that helps understand why this story is needed.
29 | validations:
30 | required: true
31 | - type: textarea
32 | id: acceptance-criteria
33 | attributes:
34 | label: Acceptance Criteria
35 | description: Define the specific criteria that must be met for this story to be considered complete
36 | placeholder: |
37 | - [ ] Criterion 1
38 | - [ ] Criterion 2
39 | - [ ] Criterion 3
40 |
41 | Given [initial context]
42 | When [action taken]
43 | Then [expected result]
44 | validations:
45 | required: true
46 | - type: textarea
47 | id: notes
48 | attributes:
49 | label: Notes
50 | description: Any additional notes, considerations, or implementation details
51 | placeholder: |
52 | - Technical considerations
53 | - Dependencies
54 | - Edge cases to consider
55 | - Design considerations
56 | validations:
57 | required: false
58 | - type: textarea
59 | id: non-functional-requirements
60 | attributes:
61 | label: Non-Functional Requirements
62 | description: Specify any non-functional requirements such as performance, security, usability, etc.
63 | placeholder: |
64 | - Performance: [e.g., response time < 2 seconds]
65 | - Security: [e.g., authentication required]
66 | - Usability: [e.g., accessible via keyboard navigation]
67 | - Scalability: [e.g., support up to 1000 concurrent users]
68 | validations:
69 | required: false
70 | - type: dropdown
71 | id: priority
72 | attributes:
73 | label: Priority
74 | description: What is the priority of this story?
75 | options:
76 | - Low
77 | - Medium
78 | - High
79 | - Critical
80 | validations:
81 | required: false
82 | - type: input
83 | id: story-points
84 | attributes:
85 | label: Story Points (estimate)
86 | description: Estimated complexity/effort for this story
87 | placeholder: "e.g., 1, 2, 3, 5, 8, 13"
88 | validations:
89 | required: false
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/copilot.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Copilot Request
2 | description: Request assistance from GitHub Copilot for code generation, review, or optimization
3 | title: "[Copilot]: "
4 | labels: ["copilot", "ai-assistance"]
5 | assignees: ["Copilot"]
6 | body:
7 | - type: markdown
8 | attributes:
9 | value: |
10 | 🤖 **Copilot Request Template**
11 |
12 | This issue will be automatically assigned to @copilot for AI-powered assistance. Please provide detailed information about what you need help with.
13 | - type: dropdown
14 | id: request-type
15 | attributes:
16 | label: Type of Assistance
17 | description: What kind of help do you need from Copilot?
18 | options:
19 | - Code Generation
20 | - Code Review
21 | - Bug Investigation
22 | - Optimization
23 | - Documentation
24 | - Testing
25 | - Refactoring
26 | - Architecture Advice
27 | - Other
28 | validations:
29 | required: true
30 | - type: textarea
31 | id: description
32 | attributes:
33 | label: Description
34 | description: Describe what you need assistance with
35 | placeholder: |
36 | Provide a clear description of what you need help with. Be as specific as possible.
37 | validations:
38 | required: true
39 | - type: textarea
40 | id: context
41 | attributes:
42 | label: Context
43 | description: Provide relevant context, existing code, or background information
44 | placeholder: |
45 | - Current implementation (if any)
46 | - Related files or modules
47 | - Constraints or requirements
48 | - Previous attempts or approaches tried
49 | validations:
50 | required: false
51 | - type: textarea
52 | id: requirements
53 | attributes:
54 | label: Requirements & Constraints
55 | description: Specify any specific requirements, constraints, or preferences
56 | placeholder: |
57 | - Language/framework preferences
58 | - Performance requirements
59 | - Coding standards to follow
60 | - Compatibility requirements
61 | - Security considerations
62 | validations:
63 | required: false
64 | - type: textarea
65 | id: expected-outcome
66 | attributes:
67 | label: Expected Outcome
68 | description: What do you expect as the result of this request?
69 | placeholder: |
70 | Describe what you expect to receive:
71 | - Code examples
72 | - Recommendations
73 | - Analysis
74 | - Documentation updates
75 | - Test cases
76 | validations:
77 | required: true
78 | - type: dropdown
79 | id: urgency
80 | attributes:
81 | label: Urgency
82 | description: How urgent is this request?
83 | options:
84 | - Low - When time permits
85 | - Medium - Within a few days
86 | - High - Within 24 hours
87 | - Critical - ASAP
88 | validations:
89 | required: false
90 | - type: textarea
91 | id: additional-info
92 | attributes:
93 | label: Additional Information
94 | description: Any other relevant information, links, or resources
95 | placeholder: |
96 | - Links to documentation
97 | - Related issues or PRs
98 | - External resources
99 | - Screenshots or diagrams
100 | validations:
101 | required: false
```
--------------------------------------------------------------------------------
/claude_reference/jira_shell.txt:
--------------------------------------------------------------------------------
```
1 | 3. jirashell
2 | There is no substitute for play. The only way to really know a service, API or package is to explore it, poke at it, and bang your elbows – trial and error. A REST design is especially well-suited for active exploration, and the jirashell script (installed automatically when you use pip) is designed to help you do exactly that.
3 |
4 | pip install jira[cli]
5 | Run it from the command line
6 |
7 | jirashell -s https://jira.atlassian.com
8 | <Jira Shell (https://jira.atlassian.com)>
9 |
10 | *** Jira shell active; client is in 'jira'. Press Ctrl-D to exit.
11 |
12 | In [1]:
13 | This is a specialized Python interpreter (built on IPython) that lets you explore Jira as a service. Any legal Python code is acceptable input. The shell builds a JIRA client object for you (based on the launch parameters) and stores it in the jira object.
14 |
15 | Try getting an issue
16 |
17 | In [1]: issue = jira.issue('JRA-1330')
18 | issue now contains a reference to an issue Resource. To see the available properties and methods, hit the TAB key
19 |
20 | In [2]: issue.
21 | issue.delete issue.fields issue.id issue.raw issue.update
22 | issue.expand issue.find issue.key issue.self
23 |
24 | In [2]: issue.fields.
25 | issue.fields.aggregateprogress issue.fields.customfield_11531
26 | issue.fields.aggregatetimeestimate issue.fields.customfield_11631
27 | issue.fields.aggregatetimeoriginalestimate issue.fields.customfield_11930
28 | issue.fields.aggregatetimespent issue.fields.customfield_12130
29 | issue.fields.assignee issue.fields.customfield_12131
30 | issue.fields.attachment issue.fields.description
31 | issue.fields.comment issue.fields.environment
32 | issue.fields.components issue.fields.fixVersions
33 | issue.fields.created issue.fields.issuelinks
34 | issue.fields.customfield_10150 issue.fields.issuetype
35 | issue.fields.customfield_10160 issue.fields.labels
36 | issue.fields.customfield_10161 issue.fields.mro
37 | issue.fields.customfield_10180 issue.fields.progress
38 | issue.fields.customfield_10230 issue.fields.project
39 | issue.fields.customfield_10575 issue.fields.reporter
40 | issue.fields.customfield_10610 issue.fields.resolution
41 | issue.fields.customfield_10650 issue.fields.resolutiondate
42 | issue.fields.customfield_10651 issue.fields.status
43 | issue.fields.customfield_10680 issue.fields.subtasks
44 | issue.fields.customfield_10723 issue.fields.summary
45 | issue.fields.customfield_11130 issue.fields.timeestimate
46 | issue.fields.customfield_11230 issue.fields.timeoriginalestimate
47 | issue.fields.customfield_11431 issue.fields.timespent
48 | issue.fields.customfield_11433 issue.fields.updated
49 | issue.fields.customfield_11434 issue.fields.versions
50 | issue.fields.customfield_11435 issue.fields.votes
51 | issue.fields.customfield_11436 issue.fields.watches
52 | issue.fields.customfield_11437 issue.fields.workratio
53 | Since the Resource class maps the server’s JSON response directly into a Python object with attribute access, you can see exactly what’s in your resources.
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/task.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Task
2 | description: Create a task for work that needs to be done
3 | title: "[Task]: "
4 | labels: ["task"]
5 | assignees: []
6 | body:
7 | - type: markdown
8 | attributes:
9 | value: |
10 | Thanks for creating a task! Please provide details about the work that needs to be done.
11 | - type: textarea
12 | id: user-story
13 | attributes:
14 | label: User Story (if applicable)
15 | description: If this task relates to a user story, write it in the format "As a [user type], I want [goal] so that [benefit]"
16 | placeholder: |
17 | As a [user type],
18 | I want [goal],
19 | so that [benefit].
20 |
21 | (Leave blank if not applicable)
22 | validations:
23 | required: false
24 | - type: textarea
25 | id: task-description
26 | attributes:
27 | label: Task Description
28 | description: Describe what needs to be done
29 | placeholder: |
30 | Provide a clear description of the task to be completed.
31 | validations:
32 | required: true
33 | - type: textarea
34 | id: background
35 | attributes:
36 | label: Background
37 | description: Provide context and background information for this task
38 | placeholder: |
39 | Describe the background, context, and any relevant information that helps understand why this task is needed.
40 | validations:
41 | required: false
42 | - type: textarea
43 | id: acceptance-criteria
44 | attributes:
45 | label: Acceptance Criteria
46 | description: Define the specific criteria that must be met for this task to be considered complete
47 | placeholder: |
48 | - [ ] Criterion 1
49 | - [ ] Criterion 2
50 | - [ ] Criterion 3
51 |
52 | The task is complete when:
53 | - [specific outcome 1]
54 | - [specific outcome 2]
55 | validations:
56 | required: true
57 | - type: input
58 | id: parent-issue
59 | attributes:
60 | label: Parent Issue (optional)
61 | description: Link to a parent issue if this task is part of a larger story or epic
62 | placeholder: "#123 or https://github.com/owner/repo/issues/123"
63 | validations:
64 | required: false
65 | - type: textarea
66 | id: notes
67 | attributes:
68 | label: Notes
69 | description: Any additional notes, considerations, or implementation details
70 | placeholder: |
71 | - Technical considerations
72 | - Dependencies
73 | - Implementation approach
74 | - Resources needed
75 | validations:
76 | required: false
77 | - type: textarea
78 | id: non-functional-requirements
79 | attributes:
80 | label: Non-Functional Requirements
81 | description: Specify any non-functional requirements such as performance, security, usability, etc.
82 | placeholder: |
83 | - Performance: [e.g., response time < 2 seconds]
84 | - Security: [e.g., authentication required]
85 | - Usability: [e.g., accessible via keyboard navigation]
86 | - Maintainability: [e.g., code coverage > 80%]
87 | validations:
88 | required: false
89 | - type: dropdown
90 | id: priority
91 | attributes:
92 | label: Priority
93 | description: What is the priority of this task?
94 | options:
95 | - Low
96 | - Medium
97 | - High
98 | - Critical
99 | validations:
100 | required: false
101 | - type: input
102 | id: effort-estimate
103 | attributes:
104 | label: Effort Estimate
105 | description: Estimated time or complexity for this task
106 | placeholder: "e.g., 2 hours, 1 day, 1 week, or story points: 1, 2, 3, 5, 8"
107 | validations:
108 | required: false
```
--------------------------------------------------------------------------------
/docs/create_issues_v3_conversion.md:
--------------------------------------------------------------------------------
```markdown
1 | # Convert create_issues to v3 REST API - Implementation Summary
2 |
3 | ## Overview
4 | This document summarizes the conversion of the `create_jira_issues` method from using the legacy Jira Python SDK to the v3 REST API, following the established patterns in the codebase.
5 |
6 | ## Changes Made
7 |
8 | ### 1. JiraV3APIClient - New Method
9 | **File:** `src/mcp_server_jira/jira_v3_api.py`
10 |
11 | Added `bulk_create_issues()` method:
12 | - Uses POST `/rest/api/3/issue/bulk` endpoint
13 | - Supports up to 50 issues per API specification
14 | - Takes `issue_updates` array with proper v3 API structure
15 | - Returns response with `issues` and `errors` arrays
16 |
17 | ### 2. JiraServer - Method Conversion
18 | **File:** `src/mcp_server_jira/server.py`
19 |
20 | Converted `create_jira_issues()` method:
21 | - Changed from synchronous to `async` method
22 | - Uses `self._get_v3_api_client()` instead of legacy client
23 | - Maintains existing interface and return format for backward compatibility
24 | - Added ADF (Atlassian Document Format) conversion for descriptions
25 |
26 | ### 3. Server Integration
27 | **File:** `src/mcp_server_jira/server.py` (line ~1420)
28 |
29 | Updated the tool call handler:
30 | - Changed from synchronous to `await` call
31 | - Updated logging messages to reflect async operation
32 |
33 | ## Key Technical Features
34 |
35 | ### ADF Conversion
36 | String descriptions are automatically converted to Atlassian Document Format:
37 | ```python
38 | # Input: "Simple text description"
39 | # Output:
40 | {
41 | "type": "doc",
42 | "version": 1,
43 | "content": [
44 | {
45 | "type": "paragraph",
46 | "content": [{"type": "text", "text": "Simple text description"}]
47 | }
48 | ]
49 | }
50 | ```
51 |
52 | ### Issue Type Conversion
53 | Maintains existing case conversion logic:
54 | - `"bug"` → `"Bug"`
55 | - `"new feature"` → `"New Feature"`
56 | - `"STORY"` → `"Story"`
57 | - Custom types preserved as-is
58 |
59 | ### Field Processing
60 | Preserves all existing field processing:
61 | - Project: `"PROJ"` → `{"key": "PROJ"}`
62 | - Labels: `"label"` → `["label"]`
63 | - Assignee: `"user"` → `{"name": "user"}`
64 |
65 | ### Backward Compatibility
66 | - Supports both `"issue_type"` and `"issuetype"` fields
67 | - Accepts both string and object formats for existing fields
68 | - Returns same result format as legacy implementation
69 |
70 | ## API Format Transformation
71 |
72 | ### Input Format (unchanged)
73 | ```python
74 | field_list = [
75 | {
76 | "project": "PROJ",
77 | "summary": "Test issue",
78 | "description": "Simple description",
79 | "issue_type": "bug"
80 | }
81 | ]
82 | ```
83 |
84 | ### v3 API Format (internal transformation)
85 | ```python
86 | {
87 | "issueUpdates": [
88 | {
89 | "fields": {
90 | "project": {"key": "PROJ"},
91 | "summary": "Test issue",
92 | "description": {
93 | "type": "doc",
94 | "version": 1,
95 | "content": [...]
96 | },
97 | "issuetype": {"name": "Bug"}
98 | }
99 | }
100 | ]
101 | }
102 | ```
103 |
104 | ### Output Format (unchanged)
105 | ```python
106 | [
107 | {
108 | "key": "PROJ-1",
109 | "id": "10000",
110 | "self": "https://...",
111 | "success": True
112 | }
113 | ]
114 | ```
115 |
116 | ## Error Handling
117 | - Maintains existing validation for required fields
118 | - Preserves error message format and logging
119 | - Handles v3 API errors with backward-compatible result format
120 | - Supports partial success scenarios (some issues created, some failed)
121 |
122 | ## Testing
123 | Comprehensive test suite created:
124 |
125 | 1. **Unit Tests**: `test_bulk_create_issues_v3_api.py`
126 | - v3 API client method testing
127 | - Validation and error handling
128 |
129 | 2. **Server Tests**: `test_create_jira_issues_server.py`
130 | - Server method integration
131 | - Field conversion and ADF formatting
132 |
133 | 3. **Integration Tests**: `test_create_issues_integration.py`
134 | - End-to-end workflow testing
135 | - Backward compatibility verification
136 |
137 | ## Manual Verification
138 | All functionality verified through comprehensive manual testing:
139 | - ✅ Field validation working correctly
140 | - ✅ ADF conversion functioning properly
141 | - ✅ Issue type conversion logic correct
142 | - ✅ Error handling preserved
143 | - ✅ Backward compatibility maintained
144 |
145 | ## Migration Notes
146 | This change is **fully backward compatible**:
147 | - Existing code using `create_jira_issues()` will continue to work
148 | - Same input format supported
149 | - Same output format returned
150 | - All existing field processing preserved
151 |
152 | The only visible change is that the method is now `async` and must be called with `await`.
153 |
154 | ## Future Maintenance
155 | When maintaining this code:
156 | 1. Follow the established v3 API patterns from other converted methods
157 | 2. Preserve the ADF conversion for description fields
158 | 3. Maintain backward compatibility with existing field formats
159 | 4. Use the comprehensive test suite to validate changes
```
--------------------------------------------------------------------------------
/tests/test_create_issue_v3_api_only.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for create_issue V3 API client only"""
2 |
3 | import asyncio
4 | from unittest.mock import AsyncMock, Mock, patch
5 |
6 | import pytest
7 |
8 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
9 |
10 |
11 | class TestCreateIssueV3API:
12 | """Test suite for create_issue V3 API client"""
13 |
14 | @pytest.mark.asyncio
15 | async def test_v3_api_create_issue_success(self):
16 | """Test successful create issue request with V3 API"""
17 | # Mock 201 Created response (standard for successful creation)
18 | mock_response = Mock()
19 | mock_response.status_code = 201
20 | mock_response.json.return_value = {
21 | "id": "10000",
22 | "key": "PROJ-123",
23 | "self": "https://test.atlassian.net/rest/api/3/issue/10000",
24 | }
25 | mock_response.text = '{"id":"10000","key":"PROJ-123","self":"https://test.atlassian.net/rest/api/3/issue/10000"}'
26 | mock_response.raise_for_status.return_value = None
27 |
28 | # Mock httpx client
29 | mock_client = AsyncMock()
30 | mock_client.request.return_value = mock_response
31 |
32 | client = JiraV3APIClient(
33 | server_url="https://test.atlassian.net",
34 | username="testuser",
35 | token="testtoken",
36 | )
37 |
38 | # Replace the client instance
39 | client.client = mock_client
40 |
41 | fields = {
42 | "project": {"key": "PROJ"},
43 | "summary": "Test issue",
44 | "description": "Test description",
45 | "issuetype": {"name": "Bug"},
46 | }
47 |
48 | result = await client.create_issue(fields=fields)
49 |
50 | # Verify the response
51 | assert result["id"] == "10000"
52 | assert result["key"] == "PROJ-123"
53 | assert result["self"] == "https://test.atlassian.net/rest/api/3/issue/10000"
54 |
55 | # Verify the request was made with correct parameters
56 | mock_client.request.assert_called_once()
57 | call_args = mock_client.request.call_args
58 | assert call_args[1]["method"] == "POST"
59 | assert "/rest/api/3/issue" in call_args[1]["url"]
60 |
61 | # Verify the payload
62 | payload = call_args[1]["json"]
63 | assert payload["fields"] == fields
64 |
65 | @pytest.mark.asyncio
66 | async def test_v3_api_create_issue_with_optional_params(self):
67 | """Test create issue with optional parameters"""
68 | # Mock 201 Created response
69 | mock_response = Mock()
70 | mock_response.status_code = 201
71 | mock_response.json.return_value = {
72 | "id": "10001",
73 | "key": "PROJ-124",
74 | "self": "https://test.atlassian.net/rest/api/3/issue/10001",
75 | }
76 | mock_response.text = '{"id":"10001","key":"PROJ-124","self":"https://test.atlassian.net/rest/api/3/issue/10001"}'
77 | mock_response.raise_for_status.return_value = None
78 |
79 | # Mock httpx client
80 | mock_client = AsyncMock()
81 | mock_client.request.return_value = mock_response
82 |
83 | client = JiraV3APIClient(
84 | server_url="https://test.atlassian.net",
85 | username="testuser",
86 | token="testtoken",
87 | )
88 |
89 | # Replace the client instance
90 | client.client = mock_client
91 |
92 | fields = {
93 | "project": {"key": "PROJ"},
94 | "summary": "Test issue with update",
95 | "description": "Test description",
96 | "issuetype": {"name": "Task"},
97 | }
98 |
99 | update = {"labels": [{"add": "urgent"}]}
100 |
101 | properties = [{"key": "test-property", "value": "test-value"}]
102 |
103 | result = await client.create_issue(
104 | fields=fields, update=update, properties=properties
105 | )
106 |
107 | # Verify the response
108 | assert result["id"] == "10001"
109 | assert result["key"] == "PROJ-124"
110 |
111 | # Verify the request was made with correct parameters
112 | mock_client.request.assert_called_once()
113 | call_args = mock_client.request.call_args
114 |
115 | # Verify the payload contains all optional parameters
116 | payload = call_args[1]["json"]
117 | assert payload["fields"] == fields
118 | assert payload["update"] == update
119 | assert payload["properties"] == properties
120 |
121 | @pytest.mark.asyncio
122 | async def test_v3_api_create_issue_missing_fields(self):
123 | """Test create issue with missing fields"""
124 | client = JiraV3APIClient(
125 | server_url="https://test.atlassian.net",
126 | username="testuser",
127 | token="testtoken",
128 | )
129 |
130 | with pytest.raises(ValueError, match="fields is required"):
131 | await client.create_issue(fields=None)
132 |
133 | @pytest.mark.asyncio
134 | async def test_v3_api_create_issue_empty_fields(self):
135 | """Test create issue with empty fields dict"""
136 | client = JiraV3APIClient(
137 | server_url="https://test.atlassian.net",
138 | username="testuser",
139 | token="testtoken",
140 | )
141 |
142 | with pytest.raises(ValueError, match="fields is required"):
143 | await client.create_issue(fields={})
144 |
```
--------------------------------------------------------------------------------
/tests/test_transition_issue_v3_api_only.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for transition_issue V3 API client only"""
2 |
3 | import asyncio
4 | from unittest.mock import Mock, patch, AsyncMock
5 | import pytest
6 |
7 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
8 |
9 |
10 | class TestTransitionIssueV3API:
11 | """Test suite for transition_issue V3 API client"""
12 |
13 | @pytest.mark.asyncio
14 | async def test_v3_api_transition_issue_success(self):
15 | """Test successful transition issue request with V3 API"""
16 | # Mock 204 No Content response (standard for successful transitions)
17 | mock_response = Mock()
18 | mock_response.status_code = 204
19 | mock_response.json.return_value = {}
20 | mock_response.text = ""
21 | mock_response.raise_for_status.return_value = None
22 |
23 | # Mock httpx client
24 | mock_client = AsyncMock()
25 | mock_client.request.return_value = mock_response
26 |
27 | client = JiraV3APIClient(
28 | server_url="https://test.atlassian.net",
29 | username="testuser",
30 | token="testtoken"
31 | )
32 |
33 | # Replace the client instance
34 | client.client = mock_client
35 |
36 | result = await client.transition_issue(
37 | issue_id_or_key="PROJ-123",
38 | transition_id="5"
39 | )
40 |
41 | # Verify the request was made correctly
42 | mock_client.request.assert_called_once()
43 | call_args = mock_client.request.call_args
44 |
45 | assert call_args[1]["method"] == "POST"
46 | assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
47 | assert call_args[1]["json"]["transition"]["id"] == "5"
48 | assert result == {}
49 |
50 | @pytest.mark.asyncio
51 | async def test_v3_api_transition_issue_with_comment(self):
52 | """Test transition issue with comment"""
53 | # Mock 204 No Content response
54 | mock_response = Mock()
55 | mock_response.status_code = 204
56 | mock_response.json.return_value = {}
57 | mock_response.text = ""
58 | mock_response.raise_for_status.return_value = None
59 |
60 | # Mock httpx client
61 | mock_client = AsyncMock()
62 | mock_client.request.return_value = mock_response
63 |
64 | client = JiraV3APIClient(
65 | server_url="https://test.atlassian.net",
66 | username="testuser",
67 | token="testtoken"
68 | )
69 |
70 | # Replace the client instance
71 | client.client = mock_client
72 |
73 | result = await client.transition_issue(
74 | issue_id_or_key="PROJ-123",
75 | transition_id="2",
76 | comment="Issue resolved successfully"
77 | )
78 |
79 | # Verify the request payload includes properly formatted comment
80 | call_args = mock_client.request.call_args
81 | payload = call_args[1]["json"]
82 |
83 | assert payload["transition"]["id"] == "2"
84 | assert "update" in payload
85 | assert "comment" in payload["update"]
86 | assert len(payload["update"]["comment"]) == 1
87 |
88 | comment_structure = payload["update"]["comment"][0]["add"]["body"]
89 | assert comment_structure["type"] == "doc"
90 | assert comment_structure["version"] == 1
91 | assert len(comment_structure["content"]) == 1
92 | assert comment_structure["content"][0]["type"] == "paragraph"
93 | assert comment_structure["content"][0]["content"][0]["text"] == "Issue resolved successfully"
94 |
95 | @pytest.mark.asyncio
96 | async def test_v3_api_transition_issue_with_fields(self):
97 | """Test transition issue with field updates"""
98 | # Mock 204 No Content response
99 | mock_response = Mock()
100 | mock_response.status_code = 204
101 | mock_response.json.return_value = {}
102 | mock_response.text = ""
103 | mock_response.raise_for_status.return_value = None
104 |
105 | # Mock httpx client
106 | mock_client = AsyncMock()
107 | mock_client.request.return_value = mock_response
108 |
109 | client = JiraV3APIClient(
110 | server_url="https://test.atlassian.net",
111 | username="testuser",
112 | token="testtoken"
113 | )
114 |
115 | # Replace the client instance
116 | client.client = mock_client
117 |
118 | fields = {
119 | "assignee": {"name": "john.doe"},
120 | "resolution": {"name": "Fixed"}
121 | }
122 |
123 | result = await client.transition_issue(
124 | issue_id_or_key="PROJ-123",
125 | transition_id="3",
126 | fields=fields
127 | )
128 |
129 | # Verify the request payload includes fields
130 | call_args = mock_client.request.call_args
131 | payload = call_args[1]["json"]
132 |
133 | assert payload["transition"]["id"] == "3"
134 | assert payload["fields"] == fields
135 |
136 | @pytest.mark.asyncio
137 | async def test_v3_api_transition_issue_missing_issue_key(self):
138 | """Test transition issue with missing issue key"""
139 | client = JiraV3APIClient(
140 | server_url="https://test.atlassian.net",
141 | username="testuser",
142 | token="testtoken"
143 | )
144 |
145 | with pytest.raises(ValueError, match="issue_id_or_key is required"):
146 | await client.transition_issue("", "5")
147 |
148 | @pytest.mark.asyncio
149 | async def test_v3_api_transition_issue_missing_transition_id(self):
150 | """Test transition issue with missing transition id"""
151 | client = JiraV3APIClient(
152 | server_url="https://test.atlassian.net",
153 | username="testuser",
154 | token="testtoken"
155 | )
156 |
157 | with pytest.raises(ValueError, match="transition_id is required"):
158 | await client.transition_issue("PROJ-123", "")
```
--------------------------------------------------------------------------------
/tests/test_bulk_create_issues_v3_api.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for bulk_create_issues V3 API client only"""
2 |
3 | import asyncio
4 | from unittest.mock import Mock, patch, AsyncMock
5 | import pytest
6 |
7 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
8 |
9 |
10 | class TestBulkCreateIssuesV3API:
11 | """Test suite for bulk_create_issues V3 API client"""
12 |
13 | @pytest.mark.asyncio
14 | async def test_v3_api_bulk_create_issues_success(self):
15 | """Test successful bulk create issues request with V3 API"""
16 | # Mock 201 Created response
17 | mock_response = Mock()
18 | mock_response.status_code = 201
19 | mock_response.json.return_value = {
20 | "issues": [
21 | {
22 | "id": "10000",
23 | "key": "PROJ-1",
24 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
25 | },
26 | {
27 | "id": "10001",
28 | "key": "PROJ-2",
29 | "self": "https://test.atlassian.net/rest/api/3/issue/10001"
30 | }
31 | ],
32 | "errors": []
33 | }
34 | mock_response.text = ""
35 | mock_response.raise_for_status.return_value = None
36 |
37 | # Mock httpx client
38 | mock_client = AsyncMock()
39 | mock_client.request.return_value = mock_response
40 |
41 | client = JiraV3APIClient(
42 | server_url="https://test.atlassian.net",
43 | username="testuser",
44 | token="testtoken"
45 | )
46 |
47 | # Replace the client instance
48 | client.client = mock_client
49 |
50 | # Test data
51 | issue_updates = [
52 | {
53 | "fields": {
54 | "project": {"key": "PROJ"},
55 | "summary": "First test issue",
56 | "description": {
57 | "type": "doc",
58 | "version": 1,
59 | "content": [
60 | {
61 | "type": "paragraph",
62 | "content": [{"type": "text", "text": "Test description"}]
63 | }
64 | ]
65 | },
66 | "issuetype": {"name": "Bug"}
67 | }
68 | },
69 | {
70 | "fields": {
71 | "project": {"key": "PROJ"},
72 | "summary": "Second test issue",
73 | "description": {
74 | "type": "doc",
75 | "version": 1,
76 | "content": [
77 | {
78 | "type": "paragraph",
79 | "content": [{"type": "text", "text": "Another test description"}]
80 | }
81 | ]
82 | },
83 | "issuetype": {"name": "Task"}
84 | }
85 | }
86 | ]
87 |
88 | result = await client.bulk_create_issues(issue_updates)
89 |
90 | # Verify the request was made correctly
91 | mock_client.request.assert_called_once()
92 | call_args = mock_client.request.call_args
93 |
94 | assert call_args[1]["method"] == "POST"
95 | assert "/rest/api/3/issue/bulk" in call_args[1]["url"]
96 | assert call_args[1]["json"]["issueUpdates"] == issue_updates
97 |
98 | # Verify the response
99 | assert "issues" in result
100 | assert "errors" in result
101 | assert len(result["issues"]) == 2
102 | assert result["issues"][0]["key"] == "PROJ-1"
103 | assert result["issues"][1]["key"] == "PROJ-2"
104 |
105 | @pytest.mark.asyncio
106 | async def test_v3_api_bulk_create_issues_empty_list(self):
107 | """Test bulk create issues with empty list"""
108 | client = JiraV3APIClient(
109 | server_url="https://test.atlassian.net",
110 | username="testuser",
111 | token="testtoken"
112 | )
113 |
114 | with pytest.raises(ValueError, match="issue_updates list cannot be empty"):
115 | await client.bulk_create_issues([])
116 |
117 | @pytest.mark.asyncio
118 | async def test_v3_api_bulk_create_issues_too_many(self):
119 | """Test bulk create issues with too many issues"""
120 | client = JiraV3APIClient(
121 | server_url="https://test.atlassian.net",
122 | username="testuser",
123 | token="testtoken"
124 | )
125 |
126 | # Create more than 50 issues
127 | issue_updates = [{"fields": {"project": {"key": "PROJ"}}}] * 51
128 |
129 | with pytest.raises(ValueError, match="Cannot create more than 50 issues"):
130 | await client.bulk_create_issues(issue_updates)
131 |
132 | @pytest.mark.asyncio
133 | async def test_v3_api_bulk_create_issues_with_errors(self):
134 | """Test bulk create issues response with some errors"""
135 | # Mock response with partial success
136 | mock_response = Mock()
137 | mock_response.status_code = 201
138 | mock_response.json.return_value = {
139 | "issues": [
140 | {
141 | "id": "10000",
142 | "key": "PROJ-1",
143 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
144 | }
145 | ],
146 | "errors": [
147 | {
148 | "failedElementNumber": 1,
149 | "elementErrors": {
150 | "errorMessages": ["Issue type 'InvalidType' does not exist."]
151 | }
152 | }
153 | ]
154 | }
155 | mock_response.text = ""
156 | mock_response.raise_for_status.return_value = None
157 |
158 | # Mock httpx client
159 | mock_client = AsyncMock()
160 | mock_client.request.return_value = mock_response
161 |
162 | client = JiraV3APIClient(
163 | server_url="https://test.atlassian.net",
164 | username="testuser",
165 | token="testtoken"
166 | )
167 |
168 | # Replace the client instance
169 | client.client = mock_client
170 |
171 | # Test data
172 | issue_updates = [
173 | {
174 | "fields": {
175 | "project": {"key": "PROJ"},
176 | "summary": "Valid issue",
177 | "issuetype": {"name": "Bug"}
178 | }
179 | },
180 | {
181 | "fields": {
182 | "project": {"key": "PROJ"},
183 | "summary": "Invalid issue",
184 | "issuetype": {"name": "InvalidType"}
185 | }
186 | }
187 | ]
188 |
189 | result = await client.bulk_create_issues(issue_updates)
190 |
191 | # Verify we get both success and error results
192 | assert len(result["issues"]) == 1
193 | assert len(result["errors"]) == 1
194 | assert result["issues"][0]["key"] == "PROJ-1"
195 | assert "errorMessages" in result["errors"][0]["elementErrors"]
```
--------------------------------------------------------------------------------
/tests/test_add_comment_v3_api_only.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for add_comment V3 API client only"""
2 |
3 | import asyncio
4 | from unittest.mock import Mock, patch, AsyncMock
5 | import pytest
6 |
7 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
8 |
9 |
10 | class TestAddCommentV3API:
11 | """Test suite for add_comment V3 API client"""
12 |
13 | @pytest.mark.asyncio
14 | async def test_v3_api_add_comment_success(self):
15 | """Test successful add comment request with V3 API"""
16 | # Mock successful response
17 | mock_response_data = {
18 | "id": "10000",
19 | "body": {
20 | "type": "doc",
21 | "version": 1,
22 | "content": [
23 | {
24 | "type": "paragraph",
25 | "content": [
26 | {
27 | "type": "text",
28 | "text": "This is a test comment"
29 | }
30 | ]
31 | }
32 | ]
33 | },
34 | "author": {
35 | "accountId": "5b10a2844c20165700ede21g",
36 | "displayName": "Test User",
37 | "active": True
38 | },
39 | "created": "2021-01-17T12:34:00.000+0000",
40 | "updated": "2021-01-17T12:34:00.000+0000",
41 | "self": "https://test.atlassian.net/rest/api/3/issue/10010/comment/10000"
42 | }
43 |
44 | mock_response = Mock()
45 | mock_response.status_code = 201
46 | mock_response.json.return_value = mock_response_data
47 | mock_response.text = ""
48 | mock_response.raise_for_status.return_value = None
49 |
50 | # Mock httpx client
51 | mock_client = AsyncMock()
52 | mock_client.request.return_value = mock_response
53 |
54 | client = JiraV3APIClient(
55 | server_url="https://test.atlassian.net",
56 | username="testuser",
57 | token="testtoken"
58 | )
59 |
60 | # Replace the client instance
61 | client.client = mock_client
62 |
63 | result = await client.add_comment(
64 | issue_id_or_key="PROJ-123",
65 | comment="This is a test comment"
66 | )
67 |
68 | # Verify the request was made correctly
69 | call_args = mock_client.request.call_args
70 | assert call_args[1]["method"] == "POST"
71 | assert "https://test.atlassian.net/rest/api/3/issue/PROJ-123/comment" in call_args[1]["url"]
72 |
73 | # Verify the request payload
74 | payload = call_args[1]["json"]
75 | assert payload["body"]["type"] == "doc"
76 | assert payload["body"]["version"] == 1
77 | assert len(payload["body"]["content"]) == 1
78 | assert payload["body"]["content"][0]["type"] == "paragraph"
79 | assert payload["body"]["content"][0]["content"][0]["text"] == "This is a test comment"
80 |
81 | # Verify the response
82 | assert result == mock_response_data
83 |
84 | @pytest.mark.asyncio
85 | async def test_v3_api_add_comment_with_visibility(self):
86 | """Test add comment with visibility settings"""
87 | # Mock successful response
88 | mock_response_data = {
89 | "id": "10001",
90 | "body": {
91 | "type": "doc",
92 | "version": 1,
93 | "content": [
94 | {
95 | "type": "paragraph",
96 | "content": [
97 | {
98 | "type": "text",
99 | "text": "Internal comment"
100 | }
101 | ]
102 | }
103 | ]
104 | },
105 | "visibility": {
106 | "type": "role",
107 | "value": "Administrators"
108 | }
109 | }
110 |
111 | mock_response = Mock()
112 | mock_response.status_code = 201
113 | mock_response.json.return_value = mock_response_data
114 | mock_response.text = ""
115 | mock_response.raise_for_status.return_value = None
116 |
117 | # Mock httpx client
118 | mock_client = AsyncMock()
119 | mock_client.request.return_value = mock_response
120 |
121 | client = JiraV3APIClient(
122 | server_url="https://test.atlassian.net",
123 | username="testuser",
124 | token="testtoken"
125 | )
126 |
127 | # Replace the client instance
128 | client.client = mock_client
129 |
130 | visibility = {"type": "role", "value": "Administrators"}
131 | result = await client.add_comment(
132 | issue_id_or_key="PROJ-456",
133 | comment="Internal comment",
134 | visibility=visibility
135 | )
136 |
137 | # Verify the request payload includes visibility
138 | call_args = mock_client.request.call_args
139 | payload = call_args[1]["json"]
140 |
141 | assert "visibility" in payload
142 | assert payload["visibility"]["type"] == "role"
143 | assert payload["visibility"]["value"] == "Administrators"
144 |
145 | # Verify the response
146 | assert result == mock_response_data
147 |
148 | @pytest.mark.asyncio
149 | async def test_v3_api_add_comment_missing_issue_key(self):
150 | """Test add comment with missing issue key"""
151 | client = JiraV3APIClient(
152 | server_url="https://test.atlassian.net",
153 | username="testuser",
154 | token="testtoken"
155 | )
156 |
157 | with pytest.raises(ValueError, match="issue_id_or_key is required"):
158 | await client.add_comment(
159 | issue_id_or_key="",
160 | comment="Test comment"
161 | )
162 |
163 | @pytest.mark.asyncio
164 | async def test_v3_api_add_comment_missing_comment(self):
165 | """Test add comment with missing comment text"""
166 | client = JiraV3APIClient(
167 | server_url="https://test.atlassian.net",
168 | username="testuser",
169 | token="testtoken"
170 | )
171 |
172 | with pytest.raises(ValueError, match="comment is required"):
173 | await client.add_comment(
174 | issue_id_or_key="PROJ-123",
175 | comment=""
176 | )
177 |
178 | @pytest.mark.asyncio
179 | async def test_v3_api_add_comment_with_properties(self):
180 | """Test add comment with properties"""
181 | # Mock successful response
182 | mock_response_data = {
183 | "id": "10002",
184 | "body": {
185 | "type": "doc",
186 | "version": 1,
187 | "content": [
188 | {
189 | "type": "paragraph",
190 | "content": [
191 | {
192 | "type": "text",
193 | "text": "Comment with properties"
194 | }
195 | ]
196 | }
197 | ]
198 | },
199 | "properties": [
200 | {
201 | "key": "custom-property",
202 | "value": "custom-value"
203 | }
204 | ]
205 | }
206 |
207 | mock_response = Mock()
208 | mock_response.status_code = 201
209 | mock_response.json.return_value = mock_response_data
210 | mock_response.text = ""
211 | mock_response.raise_for_status.return_value = None
212 |
213 | # Mock httpx client
214 | mock_client = AsyncMock()
215 | mock_client.request.return_value = mock_response
216 |
217 | client = JiraV3APIClient(
218 | server_url="https://test.atlassian.net",
219 | username="testuser",
220 | token="testtoken"
221 | )
222 |
223 | # Replace the client instance
224 | client.client = mock_client
225 |
226 | properties = [{"key": "custom-property", "value": "custom-value"}]
227 | result = await client.add_comment(
228 | issue_id_or_key="PROJ-789",
229 | comment="Comment with properties",
230 | properties=properties
231 | )
232 |
233 | # Verify the request payload includes properties
234 | call_args = mock_client.request.call_args
235 | payload = call_args[1]["json"]
236 |
237 | assert "properties" in payload
238 | assert len(payload["properties"]) == 1
239 | assert payload["properties"][0]["key"] == "custom-property"
240 | assert payload["properties"][0]["value"] == "custom-value"
241 |
242 | # Verify the response
243 | assert result == mock_response_data
```
--------------------------------------------------------------------------------
/tests/test_get_transitions_v3.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for get_transitions V3 API conversion"""
2 |
3 | import asyncio
4 | from unittest.mock import Mock, patch, AsyncMock
5 | import pytest
6 |
7 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
8 | from src.mcp_server_jira.server import JiraServer, JiraTransitionResult
9 |
10 |
11 | class TestGetTransitionsV3APIConversion:
12 | """Test suite for get_transitions V3 API conversion"""
13 |
14 | @pytest.mark.asyncio
15 | async def test_v3_api_get_transitions_success(self):
16 | """Test successful get transitions request with V3 API"""
17 | # Mock response data matching Jira V3 API format
18 | mock_response_data = {
19 | "transitions": [
20 | {
21 | "id": "2",
22 | "name": "Close Issue",
23 | "to": {
24 | "id": "10000",
25 | "name": "Done",
26 | "description": "Issue is done"
27 | },
28 | "hasScreen": False,
29 | "isAvailable": True
30 | },
31 | {
32 | "id": "711",
33 | "name": "QA Review",
34 | "to": {
35 | "id": "5",
36 | "name": "In Review"
37 | },
38 | "hasScreen": True,
39 | "isAvailable": True
40 | }
41 | ]
42 | }
43 |
44 | # Mock httpx response
45 | mock_response = Mock()
46 | mock_response.status_code = 200
47 | mock_response.json.return_value = mock_response_data
48 | mock_response.text = str(mock_response_data)
49 | mock_response.raise_for_status.return_value = None
50 |
51 | client = JiraV3APIClient(
52 | server_url="https://test.atlassian.net",
53 | username="testuser",
54 | token="testtoken"
55 | )
56 |
57 | with patch.object(client.client, 'request', new_callable=AsyncMock) as mock_request:
58 | mock_request.return_value = mock_response
59 |
60 | result = await client.get_transitions("PROJ-123")
61 |
62 | # Verify the result structure
63 | assert "transitions" in result
64 | assert len(result["transitions"]) == 2
65 | assert result["transitions"][0]["id"] == "2"
66 | assert result["transitions"][0]["name"] == "Close Issue"
67 | assert result["transitions"][1]["id"] == "711"
68 | assert result["transitions"][1]["name"] == "QA Review"
69 |
70 | # Verify the API call
71 | mock_request.assert_called_once()
72 | call_args = mock_request.call_args
73 | assert call_args[1]["method"] == "GET"
74 | assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
75 |
76 | @pytest.mark.asyncio
77 | async def test_v3_api_get_transitions_with_parameters(self):
78 | """Test get transitions with query parameters"""
79 | mock_response = Mock()
80 | mock_response.status_code = 200
81 | mock_response.json.return_value = {"transitions": []}
82 | mock_response.text = "{\"transitions\": []}"
83 | mock_response.raise_for_status.return_value = None
84 |
85 | client = JiraV3APIClient(
86 | server_url="https://test.atlassian.net",
87 | username="testuser",
88 | token="testtoken"
89 | )
90 |
91 | with patch.object(client.client, 'request', new_callable=AsyncMock) as mock_request:
92 | mock_request.return_value = mock_response
93 |
94 | await client.get_transitions(
95 | issue_id_or_key="PROJ-123",
96 | expand="transitions.fields",
97 | transition_id="2",
98 | skip_remote_only_condition=True
99 | )
100 |
101 | # Verify the request parameters
102 | call_args = mock_request.call_args
103 | params = call_args[1]["params"]
104 | assert params["expand"] == "transitions.fields"
105 | assert params["transitionId"] == "2"
106 | assert params["skipRemoteOnlyCondition"] is True
107 |
108 | @pytest.mark.asyncio
109 | async def test_v3_api_get_transitions_missing_issue_key(self):
110 | """Test get transitions with missing issue key"""
111 | client = JiraV3APIClient(
112 | server_url="https://test.atlassian.net",
113 | username="testuser",
114 | token="testtoken"
115 | )
116 |
117 | with pytest.raises(ValueError, match="issue_id_or_key is required"):
118 | await client.get_transitions("")
119 |
120 | @pytest.mark.asyncio
121 | async def test_jira_server_get_transitions_success(self):
122 | """Test JiraServer get_jira_transitions method"""
123 | mock_api_response = {
124 | "transitions": [
125 | {"id": "2", "name": "Close Issue"},
126 | {"id": "711", "name": "QA Review"},
127 | {"id": "31", "name": "Reopen Issue"}
128 | ]
129 | }
130 |
131 | server = JiraServer(
132 | server_url="https://test.atlassian.net",
133 | username="testuser",
134 | token="testtoken"
135 | )
136 |
137 | with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
138 | mock_get_transitions.return_value = mock_api_response
139 |
140 | result = await server.get_jira_transitions("PROJ-123")
141 |
142 | # Verify the result type and structure
143 | assert isinstance(result, list)
144 | assert len(result) == 3
145 | assert all(isinstance(t, JiraTransitionResult) for t in result)
146 |
147 | # Check specific transition details
148 | assert result[0].id == "2"
149 | assert result[0].name == "Close Issue"
150 | assert result[1].id == "711"
151 | assert result[1].name == "QA Review"
152 | assert result[2].id == "31"
153 | assert result[2].name == "Reopen Issue"
154 |
155 | # Verify the V3 API was called correctly
156 | mock_get_transitions.assert_called_once_with(issue_id_or_key="PROJ-123")
157 |
158 | @pytest.mark.asyncio
159 | async def test_jira_server_get_transitions_error_handling(self):
160 | """Test error handling in get_jira_transitions"""
161 | server = JiraServer(
162 | server_url="https://test.atlassian.net",
163 | username="testuser",
164 | token="testtoken"
165 | )
166 |
167 | with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
168 | mock_get_transitions.side_effect = Exception("API Error")
169 |
170 | with pytest.raises(ValueError) as exc_info:
171 | await server.get_jira_transitions("PROJ-123")
172 |
173 | assert "Failed to get transitions for PROJ-123" in str(exc_info.value)
174 | assert "API Error" in str(exc_info.value)
175 |
176 | @pytest.mark.asyncio
177 | async def test_jira_server_backward_compatibility(self):
178 | """Test that the new implementation maintains backward compatibility"""
179 | mock_api_response = {
180 | "transitions": [
181 | {"id": "2", "name": "Close Issue"},
182 | {"id": "711", "name": "QA Review"}
183 | ]
184 | }
185 |
186 | server = JiraServer(
187 | server_url="https://test.atlassian.net",
188 | username="testuser",
189 | token="testtoken"
190 | )
191 |
192 | with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
193 | mock_get_transitions.return_value = mock_api_response
194 |
195 | result = await server.get_jira_transitions("PROJ-123")
196 |
197 | # Verify the return type matches the original interface
198 | assert isinstance(result, list)
199 | assert all(isinstance(t, JiraTransitionResult) for t in result)
200 | assert all(hasattr(t, 'id') and hasattr(t, 'name') for t in result)
201 |
202 | # Verify specific field types
203 | assert isinstance(result[0].id, str)
204 | assert isinstance(result[0].name, str)
205 |
206 | @pytest.mark.asyncio
207 | async def test_jira_server_method_is_async(self):
208 | """Test that get_jira_transitions is properly converted to async"""
209 | server = JiraServer(
210 | server_url="https://test.atlassian.net",
211 | username="testuser",
212 | token="testtoken"
213 | )
214 |
215 | import inspect
216 | assert inspect.iscoroutinefunction(server.get_jira_transitions), \
217 | "get_jira_transitions should be an async method"
```
--------------------------------------------------------------------------------
/tests/test_create_issues_integration.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Integration test for create_jira_issues V3 API conversion
3 |
4 | This test verifies that the conversion from the legacy Jira Python SDK
5 | to the v3 REST API is working correctly for bulk issue creation.
6 |
7 | Run with: python -m pytest tests/test_create_issues_integration.py -v
8 | """
9 |
10 | import pytest
11 | from unittest.mock import Mock, AsyncMock, patch
12 | from src.mcp_server_jira.server import JiraServer
13 |
14 |
15 | class TestCreateIssuesIntegration:
16 | """Integration tests for the create_issues v3 API conversion"""
17 |
18 | @pytest.mark.asyncio
19 | async def test_full_integration_with_v3_api(self):
20 | """Test the full integration from server method to v3 API client"""
21 | # Mock successful v3 API response
22 | mock_v3_response = {
23 | "issues": [
24 | {
25 | "id": "10000",
26 | "key": "PROJ-1",
27 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
28 | },
29 | {
30 | "id": "10001",
31 | "key": "PROJ-2",
32 | "self": "https://test.atlassian.net/rest/api/3/issue/10001"
33 | }
34 | ],
35 | "errors": []
36 | }
37 |
38 | # Create mock v3 client
39 | mock_v3_client = AsyncMock()
40 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
41 |
42 | # Create server instance
43 | server = JiraServer(
44 | server_url="https://test.atlassian.net",
45 | username="testuser",
46 | password="testpass"
47 | )
48 |
49 | # Patch the v3 client creation
50 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
51 | # Test data representing a typical bulk creation request
52 | field_list = [
53 | {
54 | "project": "PROJ",
55 | "summary": "Implement user login functionality",
56 | "description": "Add OAuth2 login with Google and GitHub providers",
57 | "issue_type": "story",
58 | "labels": ["authentication", "oauth"],
59 | "priority": {"name": "High"}
60 | },
61 | {
62 | "project": "PROJ",
63 | "summary": "Fix mobile navigation bug",
64 | "description": "Navigation menu not displaying on mobile devices",
65 | "issue_type": "bug",
66 | "assignee": {"name": "john.doe"}
67 | }
68 | ]
69 |
70 | # Execute the method
71 | result = await server.create_jira_issues(field_list, prefetch=True)
72 |
73 | # Verify v3 client was called
74 | mock_v3_client.bulk_create_issues.assert_called_once()
75 |
76 | # Verify the payload transformation
77 | call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
78 | assert len(call_args) == 2
79 |
80 | # Check first issue transformation
81 | issue1 = call_args[0]["fields"]
82 | assert issue1["project"]["key"] == "PROJ"
83 | assert issue1["summary"] == "Implement user login functionality"
84 | assert issue1["issuetype"]["name"] == "Story" # Converted from "story"
85 | assert issue1["labels"] == ["authentication", "oauth"]
86 | assert issue1["priority"] == {"name": "High"}
87 |
88 | # Check ADF format for description
89 | assert issue1["description"]["type"] == "doc"
90 | assert "OAuth2 login" in str(issue1["description"])
91 |
92 | # Check second issue transformation
93 | issue2 = call_args[1]["fields"]
94 | assert issue2["project"]["key"] == "PROJ"
95 | assert issue2["summary"] == "Fix mobile navigation bug"
96 | assert issue2["issuetype"]["name"] == "Bug" # Converted from "bug"
97 | assert issue2["assignee"] == {"name": "john.doe"}
98 |
99 | # Verify return format compatibility
100 | assert len(result) == 2
101 | assert result[0]["key"] == "PROJ-1"
102 | assert result[0]["id"] == "10000"
103 | assert result[0]["success"] is True
104 | assert result[1]["key"] == "PROJ-2"
105 | assert result[1]["success"] is True
106 |
107 | @pytest.mark.asyncio
108 | async def test_error_handling_integration(self):
109 | """Test error handling in the integrated flow"""
110 | # Mock v3 API response with partial errors
111 | mock_v3_response = {
112 | "issues": [
113 | {
114 | "id": "10000",
115 | "key": "PROJ-1",
116 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
117 | }
118 | ],
119 | "errors": [
120 | {
121 | "failedElementNumber": 1,
122 | "elementErrors": {
123 | "errorMessages": ["Invalid issue type 'InvalidType'"]
124 | }
125 | }
126 | ]
127 | }
128 |
129 | # Create mock v3 client
130 | mock_v3_client = AsyncMock()
131 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
132 |
133 | # Create server instance
134 | server = JiraServer(
135 | server_url="https://test.atlassian.net",
136 | username="testuser",
137 | password="testpass"
138 | )
139 |
140 | # Patch the v3 client creation
141 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
142 | field_list = [
143 | {
144 | "project": "PROJ",
145 | "summary": "Valid issue",
146 | "description": "This should work",
147 | "issue_type": "Bug"
148 | },
149 | {
150 | "project": "PROJ",
151 | "summary": "Invalid issue",
152 | "description": "This should fail",
153 | "issue_type": "InvalidType"
154 | }
155 | ]
156 |
157 | result = await server.create_jira_issues(field_list)
158 |
159 | # Should get both success and error results
160 | assert len(result) == 2
161 |
162 | # Find success and error entries
163 | success_results = [r for r in result if r.get("success")]
164 | error_results = [r for r in result if not r.get("success")]
165 |
166 | assert len(success_results) == 1
167 | assert len(error_results) == 1
168 | assert success_results[0]["key"] == "PROJ-1"
169 | assert "error" in error_results[0]
170 |
171 | @pytest.mark.asyncio
172 | async def test_backward_compatibility_with_legacy_format(self):
173 | """Test that the method maintains backward compatibility with existing usage"""
174 | mock_v3_response = {
175 | "issues": [
176 | {
177 | "id": "10000",
178 | "key": "PROJ-1",
179 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
180 | }
181 | ],
182 | "errors": []
183 | }
184 |
185 | mock_v3_client = AsyncMock()
186 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
187 |
188 | server = JiraServer(
189 | server_url="https://test.atlassian.net",
190 | username="testuser",
191 | password="testpass"
192 | )
193 |
194 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
195 | # Test with both new and legacy field formats
196 | field_list = [
197 | {
198 | # Using 'issuetype' field (legacy format)
199 | "project": {"key": "PROJ"}, # Object format
200 | "summary": "Legacy format issue",
201 | "description": "Using legacy field formats",
202 | "issuetype": {"name": "Bug"} # Object format
203 | }
204 | ]
205 |
206 | result = await server.create_jira_issues(field_list)
207 |
208 | # Should work with legacy formats
209 | assert len(result) == 1
210 | assert result[0]["success"] is True
211 | assert result[0]["key"] == "PROJ-1"
212 |
213 | # Verify the payload was transformed correctly
214 | call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
215 | issue_fields = call_args[0]["fields"]
216 |
217 | # Legacy project object format should be preserved
218 | assert issue_fields["project"]["key"] == "PROJ"
219 | # Legacy issuetype object format should be preserved
220 | assert issue_fields["issuetype"]["name"] == "Bug"
```
--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the main server functionality.
3 | """
4 |
5 | from unittest.mock import AsyncMock, Mock, patch
6 |
7 | import pytest
8 |
9 | from src.mcp_server_jira.server import JiraProjectResult, JiraServer
10 |
11 |
12 | class TestJiraServer:
13 | """Test suite for JiraServer class"""
14 |
15 | def test_init_with_credentials(self):
16 | """Test JiraServer initialization with credentials"""
17 | server = JiraServer(
18 | server_url="https://test.atlassian.net",
19 | auth_method="token",
20 | username="testuser",
21 | token="testtoken",
22 | )
23 |
24 | assert server.server_url == "https://test.atlassian.net"
25 | assert server.auth_method == "token"
26 | assert server.username == "testuser"
27 | assert server.token == "testtoken"
28 |
29 | @patch.object(JiraServer, "_get_v3_api_client")
30 | def test_get_jira_projects(self, mock_get_v3_api_client):
31 | """Test getting Jira projects using v3 API"""
32 | # Setup mock v3 client
33 | mock_v3_client = Mock()
34 | mock_v3_client.get_projects.return_value = {
35 | "startAt": 0,
36 | "maxResults": 50,
37 | "total": 1,
38 | "isLast": True,
39 | "values": [
40 | {
41 | "id": "123",
42 | "key": "TEST",
43 | "name": "Test Project",
44 | "lead": {
45 | "displayName": "John Doe"
46 | }
47 | }
48 | ]
49 | }
50 | mock_get_v3_api_client.return_value = mock_v3_client
51 |
52 | server = JiraServer(
53 | server_url="https://test.atlassian.net",
54 | auth_method="token",
55 | username="testuser",
56 | token="testtoken",
57 | )
58 |
59 | # Call the method
60 | projects = server.get_jira_projects()
61 |
62 | # Verify results
63 | assert len(projects) == 1
64 | assert isinstance(projects[0], JiraProjectResult)
65 | assert projects[0].key == "TEST"
66 | assert projects[0].name == "Test Project"
67 | assert projects[0].id == "123"
68 | assert projects[0].lead == "John Doe"
69 |
70 | # Verify v3 client was called correctly
71 | mock_v3_client.get_projects.assert_called_with(
72 | start_at=0,
73 | max_results=50
74 | )
75 |
76 | @patch.object(JiraServer, "_get_v3_api_client")
77 | def test_get_jira_projects_pagination(self, mock_get_v3_api_client):
78 | """Test getting Jira projects with pagination"""
79 | # Setup mock v3 client with pagination
80 | mock_v3_client = Mock()
81 |
82 | # First page response
83 | page1_response = {
84 | "startAt": 0,
85 | "maxResults": 2,
86 | "total": 3,
87 | "isLast": False,
88 | "values": [
89 | {"id": "10000", "key": "TEST1", "name": "Test Project 1"},
90 | {"id": "10001", "key": "TEST2", "name": "Test Project 2"}
91 | ]
92 | }
93 |
94 | # Second page response
95 | page2_response = {
96 | "startAt": 2,
97 | "maxResults": 2,
98 | "total": 3,
99 | "isLast": True,
100 | "values": [
101 | {"id": "10002", "key": "TEST3", "name": "Test Project 3"}
102 | ]
103 | }
104 |
105 | # Configure mock to return different responses for each call
106 | mock_v3_client.get_projects.side_effect = [page1_response, page2_response]
107 | mock_get_v3_api_client.return_value = mock_v3_client
108 |
109 | server = JiraServer(
110 | server_url="https://test.atlassian.net",
111 | auth_method="token",
112 | username="testuser",
113 | token="testtoken",
114 | )
115 |
116 | # Call the method
117 | projects = server.get_jira_projects()
118 |
119 | # Should have called get_projects twice due to pagination
120 | assert mock_v3_client.get_projects.call_count == 2
121 |
122 | # Should have collected all 3 projects
123 | assert len(projects) == 3
124 | assert projects[0].key == "TEST1"
125 | assert projects[1].key == "TEST2"
126 | assert projects[2].key == "TEST3"
127 |
128 | # Verify correct pagination parameters
129 | calls = mock_v3_client.get_projects.call_args_list
130 | assert calls[0][1]["start_at"] == 0
131 | assert calls[1][1]["start_at"] == 2
132 |
133 | @patch.object(JiraServer, "_get_v3_api_client")
134 | def test_create_jira_project_v3_api(self, mock_get_v3_api_client):
135 | """Test project creation using v3 API"""
136 | # Setup mock v3 client
137 | mock_v3_client = Mock()
138 | mock_v3_client.create_project.return_value = {
139 | "self": "https://test.atlassian.net/rest/api/3/project/10000",
140 | "id": "10000",
141 | "key": "TEST",
142 | "name": "Test Project",
143 | }
144 | mock_get_v3_api_client.return_value = mock_v3_client
145 |
146 | server = JiraServer(
147 | server_url="https://test.atlassian.net",
148 | auth_method="token",
149 | username="testuser",
150 | token="testtoken",
151 | )
152 |
153 | # Call the method
154 | result = server.create_jira_project(
155 | key="TEST", name="Test Project", ptype="software"
156 | )
157 |
158 | # Verify results
159 | assert isinstance(result, JiraProjectResult)
160 | assert result.key == "TEST"
161 | assert result.name == "Test Project"
162 |
163 | # Verify v3 client was called correctly
164 | mock_v3_client.create_project.assert_called_once_with(
165 | key="TEST",
166 | name="Test Project",
167 | assignee=None,
168 | ptype="software",
169 | template_name=None,
170 | avatarId=None,
171 | issueSecurityScheme=None,
172 | permissionScheme=None,
173 | projectCategory=None,
174 | notificationScheme=None,
175 | categoryId=None,
176 | url="",
177 | )
178 |
179 | @patch.object(JiraServer, "_get_v3_api_client")
180 | def test_create_jira_project_with_template(self, mock_get_v3_api_client):
181 | """Test project creation with template using v3 API"""
182 | # Setup mock v3 client
183 | mock_v3_client = Mock()
184 | mock_v3_client.create_project.return_value = {
185 | "self": "https://test.atlassian.net/rest/api/3/project/10000",
186 | "id": "10000",
187 | "key": "TEMP",
188 | "name": "Template Project",
189 | }
190 | mock_get_v3_api_client.return_value = mock_v3_client
191 |
192 | server = JiraServer(
193 | server_url="https://test.atlassian.net",
194 | auth_method="token",
195 | username="testuser",
196 | token="testtoken",
197 | )
198 |
199 | # Call the method with template
200 | result = server.create_jira_project(
201 | key="TEMP",
202 | name="Template Project",
203 | ptype="business",
204 | template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
205 | assignee="user123",
206 | )
207 |
208 | # Verify results
209 | assert isinstance(result, JiraProjectResult)
210 | assert result.key == "TEMP"
211 | assert result.name == "Template Project"
212 |
213 | # Verify v3 client was called with template parameters
214 | mock_v3_client.create_project.assert_called_once_with(
215 | key="TEMP",
216 | name="Template Project",
217 | assignee="user123",
218 | ptype="business",
219 | template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
220 | avatarId=None,
221 | issueSecurityScheme=None,
222 | permissionScheme=None,
223 | projectCategory=None,
224 | notificationScheme=None,
225 | categoryId=None,
226 | url="",
227 | )
228 |
229 | def test_get_v3_api_client(self):
230 | """Test v3 client creation"""
231 | server = JiraServer(
232 | server_url="https://test.atlassian.net",
233 | auth_method="token",
234 | username="testuser",
235 | token="testtoken",
236 | )
237 |
238 | client = server._get_v3_api_client()
239 |
240 | assert client.server_url == "https://test.atlassian.net"
241 | assert client.username == "testuser"
242 | assert client.token == "testtoken"
243 | assert client.password is None
244 |
245 | def test_get_v3_api_client_with_password(self):
246 | """Test v3 client creation with password"""
247 | server = JiraServer(
248 | server_url="https://test.atlassian.net",
249 | auth_method="basic",
250 | username="testuser",
251 | password="testpass",
252 | )
253 |
254 | client = server._get_v3_api_client()
255 |
256 | assert client.server_url == "https://test.atlassian.net"
257 | assert client.username == "testuser"
258 | assert client.password == "testpass"
259 | assert client.token is None
260 |
```
--------------------------------------------------------------------------------
/bin/Activate.ps1:
--------------------------------------------------------------------------------
```
1 | <#
2 | .Synopsis
3 | Activate a Python virtual environment for the current PowerShell session.
4 |
5 | .Description
6 | Pushes the python executable for a virtual environment to the front of the
7 | $Env:PATH environment variable and sets the prompt to signify that you are
8 | in a Python virtual environment. Makes use of the command line switches as
9 | well as the `pyvenv.cfg` file values present in the virtual environment.
10 |
11 | .Parameter VenvDir
12 | Path to the directory that contains the virtual environment to activate. The
13 | default value for this is the parent of the directory that the Activate.ps1
14 | script is located within.
15 |
16 | .Parameter Prompt
17 | The prompt prefix to display when this virtual environment is activated. By
18 | default, this prompt is the name of the virtual environment folder (VenvDir)
19 | surrounded by parentheses and followed by a single space (ie. '(.venv) ').
20 |
21 | .Example
22 | Activate.ps1
23 | Activates the Python virtual environment that contains the Activate.ps1 script.
24 |
25 | .Example
26 | Activate.ps1 -Verbose
27 | Activates the Python virtual environment that contains the Activate.ps1 script,
28 | and shows extra information about the activation as it executes.
29 |
30 | .Example
31 | Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
32 | Activates the Python virtual environment located in the specified location.
33 |
34 | .Example
35 | Activate.ps1 -Prompt "MyPython"
36 | Activates the Python virtual environment that contains the Activate.ps1 script,
37 | and prefixes the current prompt with the specified string (surrounded in
38 | parentheses) while the virtual environment is active.
39 |
40 | .Notes
41 | On Windows, it may be required to enable this Activate.ps1 script by setting the
42 | execution policy for the user. You can do this by issuing the following PowerShell
43 | command:
44 |
45 | PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
46 |
47 | For more information on Execution Policies:
48 | https://go.microsoft.com/fwlink/?LinkID=135170
49 |
50 | #>
51 | Param(
52 | [Parameter(Mandatory = $false)]
53 | [String]
54 | $VenvDir,
55 | [Parameter(Mandatory = $false)]
56 | [String]
57 | $Prompt
58 | )
59 |
60 | <# Function declarations --------------------------------------------------- #>
61 |
62 | <#
63 | .Synopsis
64 | Remove all shell session elements added by the Activate script, including the
65 | addition of the virtual environment's Python executable from the beginning of
66 | the PATH variable.
67 |
68 | .Parameter NonDestructive
69 | If present, do not remove this function from the global namespace for the
70 | session.
71 |
72 | #>
73 | function global:deactivate ([switch]$NonDestructive) {
74 | # Revert to original values
75 |
76 | # The prior prompt:
77 | if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
78 | Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
79 | Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
80 | }
81 |
82 | # The prior PYTHONHOME:
83 | if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
84 | Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
85 | Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
86 | }
87 |
88 | # The prior PATH:
89 | if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
90 | Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
91 | Remove-Item -Path Env:_OLD_VIRTUAL_PATH
92 | }
93 |
94 | # Just remove the VIRTUAL_ENV altogether:
95 | if (Test-Path -Path Env:VIRTUAL_ENV) {
96 | Remove-Item -Path env:VIRTUAL_ENV
97 | }
98 |
99 | # Just remove VIRTUAL_ENV_PROMPT altogether.
100 | if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
101 | Remove-Item -Path env:VIRTUAL_ENV_PROMPT
102 | }
103 |
104 | # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
105 | if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
106 | Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
107 | }
108 |
109 | # Leave deactivate function in the global namespace if requested:
110 | if (-not $NonDestructive) {
111 | Remove-Item -Path function:deactivate
112 | }
113 | }
114 |
115 | <#
116 | .Description
117 | Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
118 | given folder, and returns them in a map.
119 |
120 | For each line in the pyvenv.cfg file, if that line can be parsed into exactly
121 | two strings separated by `=` (with any amount of whitespace surrounding the =)
122 | then it is considered a `key = value` line. The left hand string is the key,
123 | the right hand is the value.
124 |
125 | If the value starts with a `'` or a `"` then the first and last character is
126 | stripped from the value before being captured.
127 |
128 | .Parameter ConfigDir
129 | Path to the directory that contains the `pyvenv.cfg` file.
130 | #>
131 | function Get-PyVenvConfig(
132 | [String]
133 | $ConfigDir
134 | ) {
135 | Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
136 |
137 | # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
138 | $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
139 |
140 | # An empty map will be returned if no config file is found.
141 | $pyvenvConfig = @{ }
142 |
143 | if ($pyvenvConfigPath) {
144 |
145 | Write-Verbose "File exists, parse `key = value` lines"
146 | $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
147 |
148 | $pyvenvConfigContent | ForEach-Object {
149 | $keyval = $PSItem -split "\s*=\s*", 2
150 | if ($keyval[0] -and $keyval[1]) {
151 | $val = $keyval[1]
152 |
153 | # Remove extraneous quotations around a string value.
154 | if ("'""".Contains($val.Substring(0, 1))) {
155 | $val = $val.Substring(1, $val.Length - 2)
156 | }
157 |
158 | $pyvenvConfig[$keyval[0]] = $val
159 | Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
160 | }
161 | }
162 | }
163 | return $pyvenvConfig
164 | }
165 |
166 |
167 | <# Begin Activate script --------------------------------------------------- #>
168 |
169 | # Determine the containing directory of this script
170 | $VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
171 | $VenvExecDir = Get-Item -Path $VenvExecPath
172 |
173 | Write-Verbose "Activation script is located in path: '$VenvExecPath'"
174 | Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
175 | Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
176 |
177 | # Set values required in priority: CmdLine, ConfigFile, Default
178 | # First, get the location of the virtual environment, it might not be
179 | # VenvExecDir if specified on the command line.
180 | if ($VenvDir) {
181 | Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
182 | }
183 | else {
184 | Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
185 | $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
186 | Write-Verbose "VenvDir=$VenvDir"
187 | }
188 |
189 | # Next, read the `pyvenv.cfg` file to determine any required value such
190 | # as `prompt`.
191 | $pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
192 |
193 | # Next, set the prompt from the command line, or the config file, or
194 | # just use the name of the virtual environment folder.
195 | if ($Prompt) {
196 | Write-Verbose "Prompt specified as argument, using '$Prompt'"
197 | }
198 | else {
199 | Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
200 | if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
201 | Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
202 | $Prompt = $pyvenvCfg['prompt'];
203 | }
204 | else {
205 | Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
206 | Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
207 | $Prompt = Split-Path -Path $venvDir -Leaf
208 | }
209 | }
210 |
211 | Write-Verbose "Prompt = '$Prompt'"
212 | Write-Verbose "VenvDir='$VenvDir'"
213 |
214 | # Deactivate any currently active virtual environment, but leave the
215 | # deactivate function in place.
216 | deactivate -nondestructive
217 |
218 | # Now set the environment variable VIRTUAL_ENV, used by many tools to determine
219 | # that there is an activated venv.
220 | $env:VIRTUAL_ENV = $VenvDir
221 |
222 | if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
223 |
224 | Write-Verbose "Setting prompt to '$Prompt'"
225 |
226 | # Set the prompt to include the env name
227 | # Make sure _OLD_VIRTUAL_PROMPT is global
228 | function global:_OLD_VIRTUAL_PROMPT { "" }
229 | Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
230 | New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
231 |
232 | function global:prompt {
233 | Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
234 | _OLD_VIRTUAL_PROMPT
235 | }
236 | $env:VIRTUAL_ENV_PROMPT = $Prompt
237 | }
238 |
239 | # Clear PYTHONHOME
240 | if (Test-Path -Path Env:PYTHONHOME) {
241 | Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
242 | Remove-Item -Path Env:PYTHONHOME
243 | }
244 |
245 | # Add the venv to the PATH
246 | Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
247 | $Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
248 |
```
--------------------------------------------------------------------------------
/tests/test_create_jira_issues_server.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for create_jira_issues server method using V3 API"""
2 |
3 | import asyncio
4 | from unittest.mock import Mock, patch, AsyncMock
5 | import pytest
6 |
7 | from src.mcp_server_jira.server import JiraServer
8 |
9 |
10 | class TestCreateJiraIssuesServer:
11 | """Test suite for create_jira_issues server method"""
12 |
13 | @pytest.mark.asyncio
14 | async def test_create_jira_issues_server_success(self):
15 | """Test successful create_jira_issues through server"""
16 | # Mock the v3 API client response
17 | mock_v3_response = {
18 | "issues": [
19 | {
20 | "id": "10000",
21 | "key": "PROJ-1",
22 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
23 | },
24 | {
25 | "id": "10001",
26 | "key": "PROJ-2",
27 | "self": "https://test.atlassian.net/rest/api/3/issue/10001"
28 | }
29 | ],
30 | "errors": []
31 | }
32 |
33 | # Mock the v3 client
34 | mock_v3_client = AsyncMock()
35 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
36 |
37 | # Create server instance
38 | server = JiraServer(
39 | server_url="https://test.atlassian.net",
40 | username="testuser",
41 | password="testpass"
42 | )
43 |
44 | # Mock the _get_v3_api_client method
45 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
46 | # Test data
47 | field_list = [
48 | {
49 | "project": "PROJ",
50 | "summary": "First test issue",
51 | "description": "Test description",
52 | "issue_type": "Bug"
53 | },
54 | {
55 | "project": "PROJ",
56 | "summary": "Second test issue",
57 | "description": "Another test description",
58 | "issue_type": "Task"
59 | }
60 | ]
61 |
62 | result = await server.create_jira_issues(field_list)
63 |
64 | # Verify the v3 client was called correctly
65 | mock_v3_client.bulk_create_issues.assert_called_once()
66 | call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
67 |
68 | # Check the transformed data structure
69 | assert len(call_args) == 2
70 | assert call_args[0]["fields"]["project"]["key"] == "PROJ"
71 | assert call_args[0]["fields"]["summary"] == "First test issue"
72 | assert call_args[0]["fields"]["issuetype"]["name"] == "Bug"
73 |
74 | # Check ADF format for description
75 | assert call_args[0]["fields"]["description"]["type"] == "doc"
76 | assert call_args[0]["fields"]["description"]["version"] == 1
77 | assert "Test description" in str(call_args[0]["fields"]["description"])
78 |
79 | # Verify the return format matches the original interface
80 | assert len(result) == 2
81 | assert result[0]["key"] == "PROJ-1"
82 | assert result[0]["id"] == "10000"
83 | assert result[0]["success"] is True
84 | assert result[1]["key"] == "PROJ-2"
85 | assert result[1]["success"] is True
86 |
87 | @pytest.mark.asyncio
88 | async def test_create_jira_issues_missing_required_fields(self):
89 | """Test create_jira_issues with missing required fields"""
90 | server = JiraServer(
91 | server_url="https://test.atlassian.net",
92 | username="testuser",
93 | password="testpass"
94 | )
95 |
96 | # Test missing project
97 | with pytest.raises(ValueError, match="Each issue must have a 'project' field"):
98 | await server.create_jira_issues([
99 | {
100 | "summary": "Test issue",
101 | "description": "Test description",
102 | "issue_type": "Bug"
103 | }
104 | ])
105 |
106 | # Test missing summary
107 | with pytest.raises(ValueError, match="Each issue must have a 'summary' field"):
108 | await server.create_jira_issues([
109 | {
110 | "project": "PROJ",
111 | "description": "Test description",
112 | "issue_type": "Bug"
113 | }
114 | ])
115 |
116 | # Test missing issue type
117 | with pytest.raises(ValueError, match="Each issue must have an 'issuetype' or 'issue_type' field"):
118 | await server.create_jira_issues([
119 | {
120 | "project": "PROJ",
121 | "summary": "Test issue",
122 | "description": "Test description"
123 | }
124 | ])
125 |
126 | @pytest.mark.asyncio
127 | async def test_create_jira_issues_issue_type_conversion(self):
128 | """Test issue type conversion for common types"""
129 | mock_v3_response = {
130 | "issues": [
131 | {
132 | "id": "10000",
133 | "key": "PROJ-1",
134 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
135 | }
136 | ],
137 | "errors": []
138 | }
139 |
140 | mock_v3_client = AsyncMock()
141 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
142 |
143 | server = JiraServer(
144 | server_url="https://test.atlassian.net",
145 | username="testuser",
146 | password="testpass"
147 | )
148 |
149 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
150 | # Test lowercase issue type conversion
151 | field_list = [
152 | {
153 | "project": "PROJ",
154 | "summary": "Test issue",
155 | "description": "Test description",
156 | "issue_type": "bug" # lowercase
157 | }
158 | ]
159 |
160 | await server.create_jira_issues(field_list)
161 |
162 | # Verify issue type was converted to proper case
163 | call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
164 | assert call_args[0]["fields"]["issuetype"]["name"] == "Bug"
165 |
166 | @pytest.mark.asyncio
167 | async def test_create_jira_issues_description_adf_conversion(self):
168 | """Test that string descriptions are converted to ADF format"""
169 | mock_v3_response = {
170 | "issues": [
171 | {
172 | "id": "10000",
173 | "key": "PROJ-1",
174 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
175 | }
176 | ],
177 | "errors": []
178 | }
179 |
180 | mock_v3_client = AsyncMock()
181 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
182 |
183 | server = JiraServer(
184 | server_url="https://test.atlassian.net",
185 | username="testuser",
186 | password="testpass"
187 | )
188 |
189 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
190 | field_list = [
191 | {
192 | "project": "PROJ",
193 | "summary": "Test issue",
194 | "description": "Simple text description",
195 | "issue_type": "Bug"
196 | }
197 | ]
198 |
199 | await server.create_jira_issues(field_list)
200 |
201 | # Verify description was converted to ADF format
202 | call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
203 | description = call_args[0]["fields"]["description"]
204 |
205 | assert description["type"] == "doc"
206 | assert description["version"] == 1
207 | assert len(description["content"]) == 1
208 | assert description["content"][0]["type"] == "paragraph"
209 | assert description["content"][0]["content"][0]["text"] == "Simple text description"
210 |
211 | @pytest.mark.asyncio
212 | async def test_create_jira_issues_with_errors_in_response(self):
213 | """Test create_jira_issues handling of error responses"""
214 | mock_v3_response = {
215 | "issues": [
216 | {
217 | "id": "10000",
218 | "key": "PROJ-1",
219 | "self": "https://test.atlassian.net/rest/api/3/issue/10000"
220 | }
221 | ],
222 | "errors": [
223 | {
224 | "failedElementNumber": 1,
225 | "elementErrors": {
226 | "errorMessages": ["Invalid issue type"]
227 | }
228 | }
229 | ]
230 | }
231 |
232 | mock_v3_client = AsyncMock()
233 | mock_v3_client.bulk_create_issues.return_value = mock_v3_response
234 |
235 | server = JiraServer(
236 | server_url="https://test.atlassian.net",
237 | username="testuser",
238 | password="testpass"
239 | )
240 |
241 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
242 | field_list = [
243 | {
244 | "project": "PROJ",
245 | "summary": "Valid issue",
246 | "description": "Valid description",
247 | "issue_type": "Bug"
248 | },
249 | {
250 | "project": "PROJ",
251 | "summary": "Invalid issue",
252 | "description": "Invalid description",
253 | "issue_type": "InvalidType"
254 | }
255 | ]
256 |
257 | result = await server.create_jira_issues(field_list)
258 |
259 | # Should have one success and one error
260 | assert len(result) == 2
261 |
262 | # Find success and error results
263 | success_results = [r for r in result if r.get("success")]
264 | error_results = [r for r in result if not r.get("success")]
265 |
266 | assert len(success_results) == 1
267 | assert len(error_results) == 1
268 | assert success_results[0]["key"] == "PROJ-1"
269 | assert "error" in error_results[0]
```
--------------------------------------------------------------------------------
/tests/test_jira_v3_api.py:
--------------------------------------------------------------------------------
```python
1 | # pylint: disable=import-error, protected-access
2 | """
3 | Tests for the Jira v3 API client functionality.
4 | """
5 |
6 |
7 | from unittest.mock import Mock, patch
8 |
9 | import pytest # pylint: disable=import-error
10 |
11 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
12 |
13 |
14 | class TestJiraV3APIClient:
15 | """Test suite for JiraV3APIClient"""
16 |
17 | def test_init_with_username_password(self):
18 | """Test initialization with username and password"""
19 | client = JiraV3APIClient(
20 | server_url="https://test.atlassian.net",
21 | username="testuser",
22 | password="testpass",
23 | )
24 | assert client.server_url == "https://test.atlassian.net"
25 | assert client.username == "testuser"
26 | assert client.password == "testpass"
27 | assert client.token is None
28 |
29 | def test_init_with_token(self):
30 | """Test initialization with token only"""
31 | client = JiraV3APIClient(
32 | server_url="https://test.atlassian.net", token="test-token"
33 | )
34 | assert client.server_url == "https://test.atlassian.net"
35 | assert client.username is None
36 | assert client.password is None
37 | assert client.token == "test-token"
38 |
39 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
40 | def test_make_v3_api_request_success(self, mock_request):
41 | """Test successful API request"""
42 | # Setup mock response
43 | mock_response = Mock()
44 | mock_response.status_code = 200
45 | mock_response.json.return_value = {"key": "TEST", "name": "Test Project"}
46 | mock_request.return_value = mock_response
47 |
48 | client = JiraV3APIClient(
49 | server_url="https://test.atlassian.net",
50 | username="testuser",
51 | token="testtoken",
52 | )
53 |
54 | result = client._make_v3_api_request("POST", "/project", {"test": "data"})
55 |
56 | assert result == {"key": "TEST", "name": "Test Project"}
57 | mock_request.assert_called_once()
58 |
59 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
60 | def test_make_v3_api_request_error(self, mock_request):
61 | """Test API request with error response"""
62 | # Setup mock error response
63 | mock_response = Mock()
64 | mock_response.status_code = 400
65 | mock_response.json.return_value = {"errorMessages": ["Bad request"]}
66 | mock_request.return_value = mock_response
67 |
68 | client = JiraV3APIClient(
69 | server_url="https://test.atlassian.net",
70 | username="testuser",
71 | token="testtoken",
72 | )
73 |
74 | with pytest.raises(ValueError, match="HTTP 400"):
75 | client._make_v3_api_request("POST", "/project", {"test": "data"})
76 |
77 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
78 | def test_create_project_success(self, mock_request):
79 | """Test successful project creation"""
80 | # Setup mock response
81 | mock_response = Mock()
82 | mock_response.status_code = 201
83 | mock_response.json.return_value = {
84 | "self": "https://test.atlassian.net/rest/api/3/project/10000",
85 | "id": "10000",
86 | "key": "TEST",
87 | "name": "Test Project",
88 | }
89 | mock_request.return_value = mock_response
90 |
91 | client = JiraV3APIClient(
92 | server_url="https://test.atlassian.net",
93 | username="testuser",
94 | token="testtoken",
95 | )
96 |
97 | result = client.create_project(
98 | key="TEST", name="Test Project", ptype="software"
99 | )
100 |
101 | assert result["key"] == "TEST"
102 | assert result["name"] == "Test Project"
103 | mock_request.assert_called_once()
104 |
105 | # Verify the request was made with correct data
106 | call_args = mock_request.call_args
107 | assert call_args[1]["method"] == "POST"
108 | assert "/rest/api/3/project" in call_args[1]["url"]
109 |
110 | # Check the request body
111 | request_data = call_args[1]["json"]
112 | assert request_data["key"] == "TEST"
113 | assert request_data["name"] == "Test Project"
114 | assert request_data["projectTypeKey"] == "software"
115 | assert request_data["assigneeType"] == "PROJECT_LEAD"
116 |
117 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
118 | def test_create_project_with_template(self, mock_request):
119 | """Test project creation with template"""
120 | # Setup mock response
121 | mock_response = Mock()
122 | mock_response.status_code = 201
123 | mock_response.json.return_value = {
124 | "self": "https://test.atlassian.net/rest/api/3/project/10000",
125 | "id": "10000",
126 | "key": "TEMP",
127 | "name": "Template Project",
128 | }
129 | mock_request.return_value = mock_response
130 |
131 | client = JiraV3APIClient(
132 | server_url="https://test.atlassian.net",
133 | username="testuser",
134 | token="testtoken",
135 | )
136 |
137 | result = client.create_project(
138 | key="TEMP",
139 | name="Template Project",
140 | ptype="business",
141 | template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
142 | assignee="user123",
143 | )
144 |
145 | assert result["key"] == "TEMP"
146 | mock_request.assert_called_once()
147 |
148 | # Verify the request data includes template information
149 | call_args = mock_request.call_args
150 | request_data = call_args[1]["json"]
151 | assert (
152 | request_data["projectTemplateKey"]
153 | == "com.atlassian.jira-core-project-templates:jira-core-project-management"
154 | )
155 | assert request_data["leadAccountId"] == "user123"
156 | assert request_data["projectTypeKey"] == "business"
157 |
158 | def test_create_project_missing_key(self):
159 | """Test project creation with missing key"""
160 | client = JiraV3APIClient(
161 | server_url="https://test.atlassian.net",
162 | username="testuser",
163 | token="testtoken",
164 | )
165 |
166 | with pytest.raises(ValueError, match="Project key is required"):
167 | client.create_project(key="")
168 |
169 | def test_create_project_missing_assignee(self):
170 | """Test project creation with missing assignee"""
171 | client = JiraV3APIClient(
172 | server_url="https://test.atlassian.net",
173 | username="testuser",
174 | token="testtoken",
175 | )
176 |
177 | with pytest.raises(ValueError, match="Parameter 'assignee'"):
178 | client.create_project(key="TEST")
179 |
180 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
181 | def test_authentication_username_token(self, mock_request):
182 | """Test authentication with username and token"""
183 | mock_response = Mock()
184 | mock_response.status_code = 200
185 | mock_response.json.return_value = {"test": "data"}
186 | mock_request.return_value = mock_response
187 |
188 | client = JiraV3APIClient(
189 | server_url="https://test.atlassian.net",
190 | username="testuser",
191 | token="testtoken",
192 | )
193 |
194 | client._make_v3_api_request("GET", "/test")
195 |
196 | call_args = mock_request.call_args
197 | auth = call_args[1]["auth"]
198 | assert auth == ("testuser", "testtoken")
199 |
200 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
201 | def test_authentication_token_only(self, mock_request):
202 | """Test authentication with token only"""
203 | mock_response = Mock()
204 | mock_response.status_code = 200
205 | mock_response.json.return_value = {"test": "data"}
206 | mock_request.return_value = mock_response
207 |
208 | client = JiraV3APIClient(
209 | server_url="https://test.atlassian.net", token="testtoken"
210 | )
211 |
212 | client._make_v3_api_request("GET", "/test")
213 |
214 | call_args = mock_request.call_args
215 | headers = call_args[1]["headers"]
216 | assert "Authorization" in headers
217 | assert headers["Authorization"] == "Bearer testtoken"
218 |
219 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
220 | def test_get_projects_success(self, mock_request):
221 | """Test successful get projects request"""
222 | # Setup mock response
223 | mock_response = Mock()
224 | mock_response.status_code = 200
225 | mock_response.json.return_value = {
226 | "startAt": 0,
227 | "maxResults": 50,
228 | "total": 2,
229 | "isLast": True,
230 | "values": [
231 | {
232 | "id": "10000",
233 | "key": "TEST",
234 | "name": "Test Project",
235 | "lead": {"displayName": "John Doe"}
236 | },
237 | {
238 | "id": "10001",
239 | "key": "DEMO",
240 | "name": "Demo Project",
241 | "lead": {"displayName": "Jane Smith"}
242 | }
243 | ]
244 | }
245 | mock_request.return_value = mock_response
246 |
247 | client = JiraV3APIClient(
248 | server_url="https://test.atlassian.net",
249 | username="testuser",
250 | token="testtoken",
251 | )
252 |
253 | result = client.get_projects()
254 |
255 | assert result["total"] == 2
256 | assert len(result["values"]) == 2
257 | assert result["values"][0]["key"] == "TEST"
258 | assert result["values"][1]["key"] == "DEMO"
259 | mock_request.assert_called_once()
260 |
261 | # Verify the request was made to the correct endpoint
262 | call_args = mock_request.call_args
263 | assert call_args[1]["method"] == "GET"
264 | assert "/rest/api/3/project/search" in call_args[1]["url"]
265 |
266 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
267 | def test_get_projects_with_parameters(self, mock_request):
268 | """Test get projects with query parameters"""
269 | # Setup mock response
270 | mock_response = Mock()
271 | mock_response.status_code = 200
272 | mock_response.json.return_value = {
273 | "startAt": 10,
274 | "maxResults": 20,
275 | "total": 50,
276 | "isLast": False,
277 | "values": []
278 | }
279 | mock_request.return_value = mock_response
280 |
281 | client = JiraV3APIClient(
282 | server_url="https://test.atlassian.net",
283 | username="testuser",
284 | token="testtoken",
285 | )
286 |
287 | result = client.get_projects(
288 | start_at=10,
289 | max_results=20,
290 | order_by="name",
291 | query="test",
292 | keys=["PROJ1", "PROJ2"]
293 | )
294 |
295 | assert result["startAt"] == 10
296 | assert result["maxResults"] == 20
297 | mock_request.assert_called_once()
298 |
299 | # Verify the request URL includes query parameters
300 | call_args = mock_request.call_args
301 | url = call_args[1]["url"]
302 | assert "startAt=10" in url
303 | assert "maxResults=20" in url
304 | assert "orderBy=name" in url
305 | assert "query=test" in url
306 | assert "keys=PROJ1,PROJ2" in url
307 |
308 | @patch("src.mcp_server_jira.jira_v3_api.requests.request")
309 | def test_get_projects_error(self, mock_request):
310 | """Test get projects with error response"""
311 | # Setup mock error response
312 | mock_response = Mock()
313 | mock_response.status_code = 401
314 | mock_response.json.return_value = {"errorMessages": ["Unauthorized"]}
315 | mock_request.return_value = mock_response
316 |
317 | client = JiraV3APIClient(
318 | server_url="https://test.atlassian.net",
319 | username="testuser",
320 | token="testtoken",
321 | )
322 |
323 | with pytest.raises(ValueError, match="HTTP 401"):
324 | client.get_projects()
325 |
326 | @patch("src.mcp_server_jira.jira_v3_api.httpx.AsyncClient")
327 | async def test_get_transitions_success(self, mock_async_client):
328 | """Test successful get transitions request"""
329 | # Setup mock response
330 | mock_response = Mock()
331 | mock_response.status_code = 200
332 | mock_response.json.return_value = {
333 | "transitions": [
334 | {
335 | "id": "2",
336 | "name": "Close Issue",
337 | "to": {
338 | "id": "10000",
339 | "name": "Done",
340 | "description": "Issue is done"
341 | },
342 | "hasScreen": False,
343 | "isAvailable": True,
344 | "isConditional": False,
345 | "isGlobal": False,
346 | "isInitial": False
347 | },
348 | {
349 | "id": "711",
350 | "name": "QA Review",
351 | "to": {
352 | "id": "5",
353 | "name": "In Review",
354 | "description": "Issue is under review"
355 | },
356 | "hasScreen": True,
357 | "isAvailable": True,
358 | "isConditional": False,
359 | "isGlobal": False,
360 | "isInitial": False
361 | }
362 | ]
363 | }
364 | mock_response.raise_for_status.return_value = None
365 |
366 | # Setup mock client
367 | mock_client_instance = Mock()
368 | mock_client_instance.request.return_value = mock_response
369 | mock_async_client.return_value = mock_client_instance
370 |
371 | client = JiraV3APIClient(
372 | server_url="https://test.atlassian.net",
373 | username="testuser",
374 | token="testtoken",
375 | )
376 |
377 | result = await client.get_transitions("PROJ-123")
378 |
379 | assert "transitions" in result
380 | assert len(result["transitions"]) == 2
381 | assert result["transitions"][0]["id"] == "2"
382 | assert result["transitions"][0]["name"] == "Close Issue"
383 | assert result["transitions"][1]["id"] == "711"
384 | assert result["transitions"][1]["name"] == "QA Review"
385 |
386 | # Verify the request was made with correct parameters
387 | mock_client_instance.request.assert_called_once()
388 | call_args = mock_client_instance.request.call_args
389 | assert call_args[1]["method"] == "GET"
390 | assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
391 |
392 | @patch("src.mcp_server_jira.jira_v3_api.httpx.AsyncClient")
393 | async def test_get_transitions_with_parameters(self, mock_async_client):
394 | """Test get transitions with query parameters"""
395 | # Setup mock response
396 | mock_response = Mock()
397 | mock_response.status_code = 200
398 | mock_response.json.return_value = {"transitions": []}
399 | mock_response.raise_for_status.return_value = None
400 |
401 | # Setup mock client
402 | mock_client_instance = Mock()
403 | mock_client_instance.request.return_value = mock_response
404 | mock_async_client.return_value = mock_client_instance
405 |
406 | client = JiraV3APIClient(
407 | server_url="https://test.atlassian.net",
408 | username="testuser",
409 | token="testtoken",
410 | )
411 |
412 | await client.get_transitions(
413 | issue_id_or_key="PROJ-123",
414 | expand="transitions.fields",
415 | transition_id="2",
416 | skip_remote_only_condition=True,
417 | include_unavailable_transitions=False,
418 | sort_by_ops_bar_and_status=True
419 | )
420 |
421 | # Verify the request was made with correct parameters
422 | mock_client_instance.request.assert_called_once()
423 | call_args = mock_client_instance.request.call_args
424 | assert call_args[1]["method"] == "GET"
425 |
426 | params = call_args[1]["params"]
427 | assert params["expand"] == "transitions.fields"
428 | assert params["transitionId"] == "2"
429 | assert params["skipRemoteOnlyCondition"] is True
430 | assert params["includeUnavailableTransitions"] is False
431 | assert params["sortByOpsBarAndStatus"] is True
432 |
433 | async def test_get_transitions_missing_issue_key(self):
434 | """Test get transitions with missing issue key"""
435 | client = JiraV3APIClient(
436 | server_url="https://test.atlassian.net",
437 | username="testuser",
438 | token="testtoken",
439 | )
440 |
441 | with pytest.raises(ValueError, match="issue_id_or_key is required"):
442 | await client.get_transitions("")
443 |
```
--------------------------------------------------------------------------------
/claude_reference/jira_examples.txt:
--------------------------------------------------------------------------------
```
1 | 2. Examples
2 | Here’s a quick usage example:
3 |
4 | # This script shows how to use the client in anonymous mode
5 | # against jira.atlassian.com.
6 | from __future__ import annotations
7 |
8 | import re
9 |
10 | from jira import JIRA
11 |
12 | # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK
13 | # (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details).
14 | jira = JIRA(server="https://jira.atlassian.com")
15 |
16 | # Get all projects viewable by anonymous users.
17 | projects = jira.projects()
18 |
19 | # Sort available project keys, then return the second, third, and fourth keys.
20 | keys = sorted(project.key for project in projects)[2:5]
21 |
22 | # Get an issue.
23 | issue = jira.issue("JRA-1330")
24 | # Find all comments made by Atlassians on this issue.
25 | atl_comments = [
26 | comment
27 | for comment in issue.fields.comment.comments
28 | if re.search(r"@atlassian.com$", comment.author.key)
29 | ]
30 |
31 | # Add a comment to the issue.
32 | jira.add_comment(issue, "Comment text")
33 |
34 | # Change the issue's summary and description.
35 | issue.update(
36 | summary="I'm different!", description="Changed the summary to be different."
37 | )
38 |
39 | # Change the issue without sending updates
40 | issue.update(notify=False, description="Quiet summary update.")
41 |
42 | # You can update the entire labels field like this
43 | issue.update(fields={"labels": ["AAA", "BBB"]})
44 |
45 | # Or modify the List of existing labels. The new label is unicode with no
46 | # spaces
47 | issue.fields.labels.append("new_text")
48 | issue.update(fields={"labels": issue.fields.labels})
49 |
50 | # Send the issue away for good.
51 | issue.delete()
52 |
53 | # Linking a remote jira issue (needs applinks to be configured to work)
54 | issue = jira.issue("JRA-1330")
55 | issue2 = jira.issue("XX-23") # could also be another instance
56 | jira.add_remote_link(issue.id, issue2)
57 | Another example with methods to authenticate with your Jira:
58 |
59 | """Some simple authentication examples."""
60 |
61 | from __future__ import annotations
62 |
63 | from collections import Counter
64 |
65 | from jira import JIRA
66 | from jira.client import ResultList
67 | from jira.resources import Issue
68 |
69 | # Some Authentication Methods
70 | jira = JIRA(
71 | basic_auth=("admin", "admin"), # a username/password tuple [Not recommended]
72 | # basic_auth=("email", "API token"), # Jira Cloud: a username/token tuple
73 | # token_auth="API token", # Self-Hosted Jira (e.g. Server): the PAT token
74 | # auth=("admin", "admin"), # a username/password tuple for cookie auth [Not recommended]
75 | )
76 |
77 | # Who has authenticated
78 | myself = jira.myself()
79 |
80 | # Get the mutable application properties for this server (requires
81 | # jira-system-administrators permission)
82 | props = jira.application_properties()
83 |
84 | # Find all issues reported by the admin
85 | issues: ResultList[Issue] = jira.search_issues("assignee=admin")
86 |
87 | # Find the top three projects containing issues reported by admin
88 | top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3)
89 | This example shows how to work with Jira Agile / Jira Software (formerly GreenHopper):
90 |
91 | # This script shows how to use the client in anonymous mode
92 | # against jira.atlassian.com.
93 | from __future__ import annotations
94 |
95 | from jira.client import JIRA
96 |
97 | # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK
98 | # (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details).
99 | # Override this with the options parameter.
100 | jira = JIRA(server="https://jira.atlassian.com")
101 |
102 | # Get all boards viewable by anonymous users.
103 | boards = jira.boards()
104 |
105 | # Get the sprints in a specific board
106 | board_id = 441
107 | print(f"JIRA board: {boards[0].name} ({board_id})")
108 | sprints = jira.sprints(board_id)
109 | 2.1. Quickstart
110 | 2.1.1. Initialization
111 | Everything goes through the jira.client.JIRA object, so make one:
112 |
113 | from jira import JIRA
114 |
115 | jira = JIRA()
116 | This connects to a Jira started on your local machine at http://localhost:2990/jira, which not coincidentally is the default address for a Jira instance started from the Atlassian Plugin SDK.
117 |
118 | You can manually set the Jira server to use:
119 |
120 | jira = JIRA('https://jira.atlassian.com')
121 | 2.1.2. Authentication
122 | At initialization time, jira-python can optionally create an HTTP BASIC or use OAuth 1.0a access tokens for user authentication. These sessions will apply to all subsequent calls to the jira.client.JIRA object.
123 |
124 | The library is able to load the credentials from inside the ~/.netrc file, so put them there instead of keeping them in your source code.
125 |
126 | 2.1.2.1. Cookie Based Authentication
127 | Warning
128 |
129 | This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice here.
130 |
131 | For Jira Cloud use the basic_auth= (username, api_token) authentication
132 |
133 | Pass a tuple of (username, password) to the auth constructor argument:
134 |
135 | auth_jira = JIRA(auth=('username', 'password'))
136 | Using this method, authentication happens during the initialization of the object. If the authentication is successful, the retrieved session cookie will be used in future requests. Upon cookie expiration, authentication will happen again transparently.
137 |
138 | 2.1.2.2. HTTP BASIC
139 | 2.1.2.2.1. (username, password)
140 | Warning
141 |
142 | This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice here
143 |
144 | For Jira Cloud use the basic_auth= (username, api_token) authentication. For Self Hosted Jira (Server, Data Center), consider the Token Auth authentication.
145 |
146 | Pass a tuple of (username, password) to the basic_auth constructor argument:
147 |
148 | auth_jira = JIRA(basic_auth=('username', 'password'))
149 | 2.1.2.2.2. (username, api_token)
150 | Or pass a tuple of (email, api_token) to the basic_auth constructor argument (JIRA Cloud):
151 |
152 | auth_jira = JIRA(basic_auth=('email', 'API token'))
153 | See also
154 |
155 | For Self Hosted Jira (Server, Data Center), refer to the Token Auth Section.
156 |
157 | 2.1.2.3. OAuth
158 | Pass a dict of OAuth properties to the oauth constructor argument:
159 |
160 | # all values are samples and won't work in your code!
161 | key_cert_data = None
162 | with open(key_cert, 'r') as key_cert_file:
163 | key_cert_data = key_cert_file.read()
164 |
165 | oauth_dict = {
166 | 'access_token': 'foo',
167 | 'access_token_secret': 'bar',
168 | 'consumer_key': 'jira-oauth-consumer',
169 | 'key_cert': key_cert_data
170 | }
171 | auth_jira = JIRA(oauth=oauth_dict)
172 | Note
173 |
174 | The OAuth access tokens must be obtained and authorized ahead of time through the standard OAuth dance. For interactive use, jirashell can perform the dance with you if you don’t already have valid tokens.
175 |
176 | The access token and token secret uniquely identify the user.
177 |
178 | The consumer key must match the OAuth provider configured on the Jira server.
179 |
180 | The key cert data must be the private key that matches the public key configured on the Jira server’s OAuth provider.
181 |
182 | See https://confluence.atlassian.com/display/JIRA/Configuring+OAuth+Authentication+for+an+Application+Link for details on configuring an OAuth provider for Jira.
183 |
184 | 2.1.2.4. Token Auth
185 | 2.1.2.4.1. Jira Cloud
186 | This is also referred to as an API Token in the Jira Cloud documentation
187 |
188 | auth_jira = JIRA(basic_auth=('email', 'API token'))
189 | 2.1.2.4.2. Jira Self Hosted (incl. Jira Server/Data Center)
190 | This is also referred to as Personal Access Tokens (PATs) in the Self-Hosted Documentation. The is available from Jira Core >= 8.14:
191 |
192 | auth_jira = JIRA(token_auth='API token')
193 | 2.1.2.5. Kerberos
194 | To enable Kerberos auth, set kerberos=True:
195 |
196 | auth_jira = JIRA(kerberos=True)
197 | To pass additional options to Kerberos auth use dict kerberos_options, e.g.:
198 |
199 | auth_jira = JIRA(kerberos=True, kerberos_options={'mutual_authentication': 'DISABLED'})
200 | 2.1.3. Headers
201 | Headers can be provided to the internally used requests.Session. If the user provides a header that the jira.client.JIRA also attempts to set, the user provided header will take preference.
202 |
203 | For example if you want to use a custom User Agent:
204 |
205 | from requests_toolbelt import user_agent
206 |
207 | jira = JIRA(
208 | basic_auth=("email", "API token"),
209 | options={"headers": {"User-Agent": user_agent("my_package", "0.0.1")}},
210 | )
211 | 2.1.4. Issues
212 | Issues are objects. You get hold of them through the JIRA object:
213 |
214 | issue = jira.issue('JRA-1330')
215 | Issue JSON is marshaled automatically and used to augment the returned Issue object, so you can get direct access to fields:
216 |
217 | summary = issue.fields.summary # 'Field level security permissions'
218 | votes = issue.fields.votes.votes # 440 (at least)
219 | If you only want a few specific fields, save time by asking for them explicitly:
220 |
221 | issue = jira.issue('JRA-1330', fields='summary,comment')
222 | Reassign an issue:
223 |
224 | # requires issue assign permission, which is different from issue editing permission!
225 | jira.assign_issue(issue, 'newassignee')
226 | If you want to unassign it again, just do:
227 |
228 | jira.assign_issue(issue, None)
229 | Creating issues is easy:
230 |
231 | new_issue = jira.create_issue(project='PROJ_key_or_id', summary='New issue from jira-python',
232 | description='Look into this one', issuetype={'name': 'Bug'})
233 | Or you can use a dict:
234 |
235 | issue_dict = {
236 | 'project': {'id': 123},
237 | 'summary': 'New issue from jira-python',
238 | 'description': 'Look into this one',
239 | 'issuetype': {'name': 'Bug'},
240 | }
241 | new_issue = jira.create_issue(fields=issue_dict)
242 | You can even bulk create multiple issues:
243 |
244 | issue_list = [
245 | {
246 | 'project': {'id': 123},
247 | 'summary': 'First issue of many',
248 | 'description': 'Look into this one',
249 | 'issuetype': {'name': 'Bug'},
250 | },
251 | {
252 | 'project': {'key': 'FOO'},
253 | 'summary': 'Second issue',
254 | 'description': 'Another one',
255 | 'issuetype': {'name': 'Bug'},
256 | },
257 | {
258 | 'project': {'name': 'Bar'},
259 | 'summary': 'Last issue',
260 | 'description': 'Final issue of batch.',
261 | 'issuetype': {'name': 'Bug'},
262 | }]
263 | issues = jira.create_issues(field_list=issue_list)
264 | Note
265 |
266 | Project, summary, description and issue type are always required when creating issues. Your Jira may require additional fields for creating issues; see the jira.createmeta method for getting access to that information.
267 |
268 | Note
269 |
270 | Using bulk create will not throw an exception for a failed issue creation. It will return a list of dicts that each contain a possible error signature if that issue had invalid fields. Successfully created issues will contain the issue object as a value of the issue key.
271 |
272 | You can also update an issue’s fields with keyword arguments:
273 |
274 | issue.update(summary='new summary', description='A new summary was added')
275 | issue.update(assignee={'name': 'new_user'}) # reassigning in update requires issue edit permission
276 | or with a dict of new field values:
277 |
278 | issue.update(fields={'summary': 'new summary', 'description': 'A new summary was added'})
279 | You can suppress notifications:
280 |
281 | issue.update(notify=False, description='A quiet description change was made')
282 | and when you’re done with an issue, you can send it to the great hard drive in the sky:
283 |
284 | issue.delete()
285 | Updating components:
286 |
287 | existingComponents = []
288 | for component in issue.fields.components:
289 | existingComponents.append({"name" : component.name})
290 | issue.update(fields={"components": existingComponents})
291 | 2.1.4.1. Working with Rich Text
292 | You can use rich text in an issue’s description or comment. In order to use rich text, the body content needs to be formatted using the Atlassian Document Format (ADF):
293 |
294 | jira = JIRA(basic_auth=("email", "API token"))
295 | comment = {
296 | "type": "doc",
297 | "version": 1,
298 | "content": [
299 | {
300 | "type": "codeBlock",
301 | "content": [
302 | {
303 | "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.",
304 | "type": "text"
305 | }
306 | ]
307 | }
308 | ]
309 | }
310 | jira.add_comment("AB-123", comment)
311 | 2.1.5. Fields
312 | Example for accessing the worklogs:
313 |
314 | issue.fields.worklog.worklogs # list of Worklog objects
315 | issue.fields.worklog.worklogs[0].author
316 | issue.fields.worklog.worklogs[0].comment
317 | issue.fields.worklog.worklogs[0].created
318 | issue.fields.worklog.worklogs[0].id
319 | issue.fields.worklog.worklogs[0].self
320 | issue.fields.worklog.worklogs[0].started
321 | issue.fields.worklog.worklogs[0].timeSpent
322 | issue.fields.worklog.worklogs[0].timeSpentSeconds
323 | issue.fields.worklog.worklogs[0].updateAuthor # dictionary
324 | issue.fields.worklog.worklogs[0].updated
325 |
326 |
327 | issue.fields.timetracking.remainingEstimate # may be NULL or string ("0m", "2h"...)
328 | issue.fields.timetracking.remainingEstimateSeconds # may be NULL or integer
329 | issue.fields.timetracking.timeSpent # may be NULL or string
330 | issue.fields.timetracking.timeSpentSeconds # may be NULL or integer
331 | 2.1.6. Searching
332 | Leverage the power of JQL to quickly find the issues you want:
333 |
334 | # Search returns first 50 results, `maxResults` must be set to exceed this
335 | issues_in_proj = jira.search_issues('project=PROJ')
336 | all_proj_issues_but_mine = jira.search_issues('project=PROJ and assignee != currentUser()')
337 |
338 | # my top 5 issues due by the end of the week, ordered by priority
339 | oh_crap = jira.search_issues('assignee = currentUser() and due < endOfWeek() order by priority desc', maxResults=5)
340 |
341 | # Summaries of my last 3 reported issues
342 | for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3):
343 | print('{}: {}'.format(issue.key, issue.fields.summary))
344 | 2.1.7. Comments
345 | Comments, like issues, are objects. Access issue comments through the parent Issue object or the JIRA object’s dedicated method:
346 |
347 | comments_a = issue.fields.comment.comments
348 | comments_b = jira.comments(issue) # comments_b == comments_a
349 | Obtain an individual comment if you know its ID:
350 |
351 | comment = jira.comment('JRA-1330', '10234')
352 | Obtain comment author name and comment creation timestamp if you know its ID:
353 |
354 | author = jira.comment('JRA-1330', '10234').author.displayName
355 | time = jira.comment('JRA-1330', '10234').created
356 | Adding, editing and deleting comments is similarly straightforward:
357 |
358 | comment = jira.add_comment('JRA-1330', 'new comment') # no Issue object required
359 | comment = jira.add_comment(issue, 'new comment', visibility={'type': 'role', 'value': 'Administrators'}) # for admins only
360 |
361 | comment.update(body='updated comment body')
362 | comment.update(body='updated comment body but no mail notification', notify=False)
363 | comment.delete()
364 | Get all images from a comment:
365 |
366 | issue = jira.issue('JRA-1330')
367 | regex_for_png = re.compile(r'\!(\S+?\.(jpg|png|bmp))\|?\S*?\!')
368 | pngs_used_in_comment = regex_for_png.findall(issue.fields.comment.comments[0].body)
369 | for attachment in issue.fields.attachment:
370 | if attachment.filename in pngs_used_in_comment:
371 | with open(attachment.filename, 'wb') as f:
372 | f.write(attachment.get())
373 | 2.1.8. Transitions
374 | Learn what transitions are available on an issue:
375 |
376 | issue = jira.issue('PROJ-1')
377 | transitions = jira.transitions(issue)
378 | [(t['id'], t['name']) for t in transitions] # [(u'5', u'Resolve Issue'), (u'2', u'Close Issue')]
379 | Note
380 |
381 | Only the transitions available to the currently authenticated user will be returned!
382 |
383 | Then perform a transition on an issue:
384 |
385 | # Resolve the issue and assign it to 'pm_user' in one step
386 | jira.transition_issue(issue, '5', assignee={'name': 'pm_user'}, resolution={'id': '3'})
387 |
388 | # The above line is equivalent to:
389 | jira.transition_issue(issue, '5', fields={'assignee':{'name': 'pm_user'}, 'resolution':{'id': '3'}})
390 | 2.1.9. Projects
391 | Projects are objects, just like issues:
392 |
393 | projects = jira.projects()
394 | Also, just like issue objects, project objects are augmented with their fields:
395 |
396 | jra = jira.project('JRA')
397 | print(jra.name) # 'JIRA'
398 | print(jra.lead.displayName) # 'John Doe [ACME Inc.]'
399 | It’s no trouble to get the components, versions or roles either (assuming you have permission):
400 |
401 | components = jira.project_components(jra)
402 | [c.name for c in components] # 'Accessibility', 'Activity Stream', 'Administration', etc.
403 |
404 | jira.project_roles(jra) # 'Administrators', 'Developers', etc.
405 |
406 | versions = jira.project_versions(jra)
407 | [v.name for v in reversed(versions)] # '5.1.1', '5.1', '5.0.7', '5.0.6', etc.
408 | 2.1.10. Watchers
409 | Watchers are objects, represented by jira.resources.Watchers:
410 |
411 | watcher = jira.watchers(issue)
412 | print("Issue has {} watcher(s)".format(watcher.watchCount))
413 | for watcher in watcher.watchers:
414 | print(watcher)
415 | # watcher is instance of jira.resources.User:
416 | print(watcher.emailAddress)
417 | You can add users to watchers by their name:
418 |
419 | jira.add_watcher(issue, 'username')
420 | jira.add_watcher(issue, user_resource.name)
421 | And of course you can remove users from watcher:
422 |
423 | jira.remove_watcher(issue, 'username')
424 | jira.remove_watcher(issue, user_resource.name)
425 | 2.1.11. Attachments
426 | Attachments let user add files to issues. First you’ll need an issue to which the attachment will be uploaded. Next, you’ll need the file itself that is going to be attachment. The file could be a file-like object or string, representing path on the local machine. You can also modify the final name of the attachment if you don’t like original. Here are some examples:
427 |
428 | # upload file from `/some/path/attachment.txt`
429 | jira.add_attachment(issue=issue, attachment='/some/path/attachment.txt')
430 |
431 | # read and upload a file (note binary mode for opening, it's important):
432 | with open('/some/path/attachment.txt', 'rb') as f:
433 | jira.add_attachment(issue=issue, attachment=f)
434 |
435 | # attach file from memory (you can skip IO operations). In this case you MUST provide `filename`.
436 | from io import StringIO
437 | attachment = StringIO()
438 | attachment.write(data)
439 | jira.add_attachment(issue=issue, attachment=attachment, filename='content.txt')
440 | If you would like to list all available attachment, you can do it with through attachment field:
441 |
442 | for attachment in issue.fields.attachment:
443 | print("Name: '{filename}', size: {size}".format(
444 | filename=attachment.filename, size=attachment.size))
445 | # to read content use `get` method:
446 | print("Content: '{}'".format(attachment.get()))
447 | You can delete attachment by id:
448 |
449 | # Find issues with attachments:
450 | query = jira.search_issues(jql_str="attachments is not EMPTY", json_result=True, fields="key, attachment")
451 |
452 | # And remove attachments one by one
453 | for i in query['issues']:
454 | for a in i['fields']['attachment']:
455 | print("For issue {0}, found attach: '{1}' [{2}].".format(i['key'], a['filename'], a['id']))
456 | jira.delete_attachment(a['id'])
457 |
```
--------------------------------------------------------------------------------
/tests/test_search_issues_v3_api.py:
--------------------------------------------------------------------------------
```python
1 | """Test cases for search_issues V3 API client and server integration"""
2 |
3 | import asyncio
4 | from unittest.mock import Mock, patch, AsyncMock
5 | import pytest
6 |
7 | from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
8 | from src.mcp_server_jira.server import JiraServer, JiraIssueResult
9 |
10 |
11 | class TestSearchIssuesV3API:
12 | """Test suite for search_issues V3 API client"""
13 |
14 | @pytest.mark.asyncio
15 | async def test_v3_api_search_issues_success(self):
16 | """Test successful search issues request with V3 API"""
17 | # Mock successful search response
18 | mock_response = Mock()
19 | mock_response.status_code = 200
20 | mock_response.json.return_value = {
21 | "issues": [
22 | {
23 | "key": "PROJ-123",
24 | "fields": {
25 | "summary": "Test issue summary",
26 | "description": "Test issue description",
27 | "status": {"name": "Open"},
28 | "assignee": {"displayName": "John Doe"},
29 | "reporter": {"displayName": "Jane Smith"},
30 | "created": "2023-01-01T00:00:00.000+0000",
31 | "updated": "2023-01-02T00:00:00.000+0000"
32 | }
33 | },
34 | {
35 | "key": "PROJ-124",
36 | "fields": {
37 | "summary": "Another test issue",
38 | "description": "Another description",
39 | "status": {"name": "In Progress"},
40 | "assignee": None,
41 | "reporter": {"displayName": "Bob Wilson"},
42 | "created": "2023-01-03T00:00:00.000+0000",
43 | "updated": "2023-01-04T00:00:00.000+0000"
44 | }
45 | }
46 | ],
47 | "startAt": 0,
48 | "maxResults": 50,
49 | "total": 2,
50 | "isLast": True
51 | }
52 | mock_response.text = ""
53 | mock_response.raise_for_status.return_value = None
54 |
55 | # Mock httpx client
56 | mock_client = AsyncMock()
57 | mock_client.request.return_value = mock_response
58 |
59 | client = JiraV3APIClient(
60 | server_url="https://test.atlassian.net",
61 | username="testuser",
62 | token="testtoken"
63 | )
64 |
65 | # Replace the client instance
66 | client.client = mock_client
67 |
68 | result = await client.search_issues(
69 | jql="project = PROJ",
70 | max_results=10
71 | )
72 |
73 | # Verify the request was made correctly
74 | mock_client.request.assert_called_once()
75 | call_args = mock_client.request.call_args
76 |
77 | assert call_args[1]["method"] == "GET"
78 | assert call_args[1]["url"] == "https://test.atlassian.net/rest/api/3/search/jql"
79 | assert call_args[1]["params"]["jql"] == "project = PROJ"
80 | assert call_args[1]["params"]["maxResults"] == 10
81 |
82 | # Verify response
83 | assert result["total"] == 2
84 | assert len(result["issues"]) == 2
85 | assert result["issues"][0]["key"] == "PROJ-123"
86 |
87 | @pytest.mark.asyncio
88 | async def test_v3_api_search_issues_with_parameters(self):
89 | """Test search issues with optional parameters"""
90 | # Mock successful search response
91 | mock_response = Mock()
92 | mock_response.status_code = 200
93 | mock_response.json.return_value = {
94 | "issues": [],
95 | "startAt": 0,
96 | "maxResults": 25,
97 | "total": 0,
98 | "isLast": True
99 | }
100 | mock_response.text = ""
101 | mock_response.raise_for_status.return_value = None
102 |
103 | # Mock httpx client
104 | mock_client = AsyncMock()
105 | mock_client.request.return_value = mock_response
106 |
107 | client = JiraV3APIClient(
108 | server_url="https://test.atlassian.net",
109 | username="testuser",
110 | token="testtoken"
111 | )
112 |
113 | # Replace the client instance
114 | client.client = mock_client
115 |
116 | result = await client.search_issues(
117 | jql="project = PROJ AND status = Open",
118 | start_at=10,
119 | max_results=25,
120 | fields="summary,status,assignee",
121 | expand="changelog"
122 | )
123 |
124 | # Verify the request was made correctly
125 | mock_client.request.assert_called_once()
126 | call_args = mock_client.request.call_args
127 |
128 | assert call_args[1]["method"] == "GET"
129 | assert call_args[1]["url"] == "https://test.atlassian.net/rest/api/3/search/jql"
130 | params = call_args[1]["params"]
131 | assert params["jql"] == "project = PROJ AND status = Open"
132 | assert params["startAt"] == 10
133 | assert params["maxResults"] == 25
134 | assert params["fields"] == "summary,status,assignee"
135 | assert params["expand"] == "changelog"
136 |
137 | @pytest.mark.asyncio
138 | async def test_v3_api_search_issues_missing_jql(self):
139 | """Test search issues with missing JQL parameter"""
140 | client = JiraV3APIClient(
141 | server_url="https://test.atlassian.net",
142 | username="testuser",
143 | token="testtoken"
144 | )
145 |
146 | with pytest.raises(ValueError, match="jql parameter is required"):
147 | await client.search_issues("")
148 |
149 | @pytest.mark.asyncio
150 | async def test_v3_api_search_issues_api_error(self):
151 | """Test search issues with API error response"""
152 | # Mock error response
153 | mock_response = Mock()
154 | mock_response.status_code = 400
155 | mock_response.reason_phrase = "Bad Request"
156 | mock_response.json.return_value = {"errorMessages": ["Invalid JQL"]}
157 |
158 | from httpx import HTTPStatusError, Request, Response
159 | mock_request = Mock(spec=Request)
160 | mock_request.url = "https://test.atlassian.net/rest/api/3/search/jql"
161 |
162 | # Mock httpx client
163 | mock_client = AsyncMock()
164 | mock_client.request.side_effect = HTTPStatusError(
165 | "400 Bad Request", request=mock_request, response=mock_response
166 | )
167 |
168 | client = JiraV3APIClient(
169 | server_url="https://test.atlassian.net",
170 | username="testuser",
171 | token="testtoken"
172 | )
173 |
174 | # Replace the client instance
175 | client.client = mock_client
176 |
177 | with pytest.raises(ValueError, match="Jira API returned an error: 400"):
178 | await client.search_issues(jql="invalid jql syntax")
179 |
180 |
181 | class TestSearchIssuesJiraServer:
182 | """Test suite for search_issues in JiraServer class"""
183 |
184 | @pytest.mark.asyncio
185 | async def test_server_search_issues_success(self):
186 | """Test JiraServer search_issues method with successful V3 API response"""
187 | # Mock V3 API response
188 | mock_v3_response = {
189 | "issues": [
190 | {
191 | "key": "TEST-1",
192 | "fields": {
193 | "summary": "Test Summary",
194 | "description": "Test Description",
195 | "status": {"name": "Open"},
196 | "assignee": {"displayName": "Test User"},
197 | "reporter": {"displayName": "Reporter User"},
198 | "created": "2023-01-01T00:00:00.000+0000",
199 | "updated": "2023-01-02T00:00:00.000+0000"
200 | }
201 | }
202 | ]
203 | }
204 |
205 | # Mock V3 API client
206 | mock_v3_client = AsyncMock()
207 | mock_v3_client.search_issues.return_value = mock_v3_response
208 |
209 | # Create JiraServer instance and mock the V3 client
210 | server = JiraServer()
211 | server.server_url = "https://test.atlassian.net"
212 | server.username = "testuser"
213 | server.token = "testtoken"
214 |
215 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
216 | result = await server.search_jira_issues("project = TEST", max_results=10)
217 |
218 | # Verify the result
219 | assert isinstance(result, list)
220 | assert len(result) == 1
221 | issue = result[0]
222 | assert isinstance(issue, dict)
223 | assert issue["key"] == "TEST-1"
224 | assert issue["fields"]["summary"] == "Test Summary"
225 | assert issue["fields"]["description"] == "Test Description"
226 | assert issue["fields"]["status"]["name"] == "Open"
227 | assert issue["fields"]["assignee"]["displayName"] == "Test User"
228 | assert issue["fields"]["reporter"]["displayName"] == "Reporter User"
229 | assert issue["fields"]["created"] == "2023-01-01T00:00:00.000+0000"
230 | assert issue["fields"]["updated"] == "2023-01-02T00:00:00.000+0000"
231 |
232 | # Verify V3 client was called correctly
233 | mock_v3_client.search_issues.assert_called_once_with(
234 | jql="project = TEST", start_at=0, max_results=10
235 | )
236 |
237 | @pytest.mark.asyncio
238 | async def test_server_search_issues_handles_missing_fields(self):
239 | """Test JiraServer search_issues method handles missing optional fields gracefully"""
240 | # Mock V3 API response with minimal data
241 | mock_v3_response = {
242 | "issues": [
243 | {
244 | "key": "TEST-2",
245 | "fields": {
246 | "summary": "Basic Summary",
247 | # Missing description, status, assignee, reporter, etc.
248 | }
249 | }
250 | ]
251 | }
252 |
253 | # Mock V3 API client
254 | mock_v3_client = AsyncMock()
255 | mock_v3_client.search_issues.return_value = mock_v3_response
256 |
257 | # Create JiraServer instance and mock the V3 client
258 | server = JiraServer()
259 | server.server_url = "https://test.atlassian.net"
260 | server.username = "testuser"
261 | server.token = "testtoken"
262 |
263 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
264 | result = await server.search_jira_issues("project = TEST")
265 |
266 | # Verify the result handles missing fields gracefully
267 | assert isinstance(result, list)
268 | assert len(result) == 1
269 | issue = result[0]
270 | assert isinstance(issue, dict)
271 | assert issue["key"] == "TEST-2"
272 | assert issue["fields"]["summary"] == "Basic Summary"
273 | # Missing description, status, assignee, reporter should be absent or None
274 | assert issue["fields"].get("description") is None
275 | assert issue["fields"].get("status") is None
276 | assert issue["fields"].get("assignee") is None
277 | assert issue["fields"].get("reporter") is None
278 |
279 | @pytest.mark.asyncio
280 | async def test_server_search_issues_api_error(self):
281 | """Test JiraServer search_issues method with API error"""
282 | # Mock V3 API client that raises an error
283 | mock_v3_client = AsyncMock()
284 | mock_v3_client.search_issues.side_effect = ValueError("API connection failed")
285 |
286 | # Create JiraServer instance and mock the V3 client
287 | server = JiraServer()
288 | server.server_url = "https://test.atlassian.net"
289 | server.username = "testuser"
290 | server.token = "testtoken"
291 |
292 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
293 | with pytest.raises(ValueError, match="Failed to search issues"):
294 | await server.search_jira_issues("project = TEST")
295 |
296 | @pytest.mark.asyncio
297 | async def test_server_search_issues_pagination(self):
298 | """Test JiraServer search_issues method handles pagination correctly"""
299 | # Mock V3 API responses for pagination
300 | # First page response
301 | page1_response = {
302 | "issues": [
303 | {
304 | "key": "TEST-1",
305 | "fields": {
306 | "summary": "First Issue",
307 | "description": "First Description",
308 | "status": {"name": "Open"},
309 | "assignee": {"displayName": "User 1"},
310 | "reporter": {"displayName": "Reporter 1"},
311 | "created": "2023-01-01T00:00:00.000+0000",
312 | "updated": "2023-01-01T00:00:00.000+0000"
313 | }
314 | },
315 | {
316 | "key": "TEST-2",
317 | "fields": {
318 | "summary": "Second Issue",
319 | "description": "Second Description",
320 | "status": {"name": "In Progress"},
321 | "assignee": {"displayName": "User 2"},
322 | "reporter": {"displayName": "Reporter 2"},
323 | "created": "2023-01-02T00:00:00.000+0000",
324 | "updated": "2023-01-02T00:00:00.000+0000"
325 | }
326 | }
327 | ],
328 | "startAt": 0,
329 | "maxResults": 2,
330 | "total": 5,
331 | "isLast": False
332 | }
333 |
334 | # Second page response
335 | page2_response = {
336 | "issues": [
337 | {
338 | "key": "TEST-3",
339 | "fields": {
340 | "summary": "Third Issue",
341 | "description": "Third Description",
342 | "status": {"name": "Done"},
343 | "assignee": {"displayName": "User 3"},
344 | "reporter": {"displayName": "Reporter 3"},
345 | "created": "2023-01-03T00:00:00.000+0000",
346 | "updated": "2023-01-03T00:00:00.000+0000"
347 | }
348 | },
349 | {
350 | "key": "TEST-4",
351 | "fields": {
352 | "summary": "Fourth Issue",
353 | "description": "Fourth Description",
354 | "status": {"name": "Closed"},
355 | "assignee": None,
356 | "reporter": {"displayName": "Reporter 4"},
357 | "created": "2023-01-04T00:00:00.000+0000",
358 | "updated": "2023-01-04T00:00:00.000+0000"
359 | }
360 | }
361 | ],
362 | "startAt": 2,
363 | "maxResults": 2,
364 | "total": 5,
365 | "isLast": False
366 | }
367 |
368 | # Third page response
369 | page3_response = {
370 | "issues": [
371 | {
372 | "key": "TEST-5",
373 | "fields": {
374 | "summary": "Fifth Issue",
375 | "description": "Fifth Description",
376 | "status": {"name": "Open"},
377 | "assignee": {"displayName": "User 5"},
378 | "reporter": {"displayName": "Reporter 5"},
379 | "created": "2023-01-05T00:00:00.000+0000",
380 | "updated": "2023-01-05T00:00:00.000+0000"
381 | }
382 | }
383 | ],
384 | "startAt": 4,
385 | "maxResults": 2,
386 | "total": 5,
387 | "isLast": True
388 | }
389 |
390 | # Mock V3 API client with side_effect to return different pages
391 | mock_v3_client = AsyncMock()
392 | mock_v3_client.search_issues.side_effect = [page1_response, page2_response, page3_response]
393 |
394 | # Create JiraServer instance and mock the V3 client
395 | server = JiraServer()
396 | server.server_url = "https://test.atlassian.net"
397 | server.username = "testuser"
398 | server.token = "testtoken"
399 |
400 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
401 | result = await server.search_jira_issues("project = TEST", max_results=10)
402 |
403 | # Verify all issues from all pages were retrieved
404 | assert isinstance(result, list)
405 | assert len(result) == 5
406 |
407 | # Check each issue dict
408 | assert isinstance(result[0], dict)
409 | assert result[0]["key"] == "TEST-1"
410 | assert result[0]["fields"]["summary"] == "First Issue"
411 | assert result[0]["fields"]["status"]["name"] == "Open"
412 |
413 | assert result[1]["key"] == "TEST-2"
414 | assert result[1]["fields"]["summary"] == "Second Issue"
415 | assert result[1]["fields"]["status"]["name"] == "In Progress"
416 |
417 | assert result[2]["key"] == "TEST-3"
418 | assert result[2]["fields"]["summary"] == "Third Issue"
419 | assert result[2]["fields"]["status"]["name"] == "Done"
420 |
421 | assert result[3]["key"] == "TEST-4"
422 | assert result[3]["fields"]["summary"] == "Fourth Issue"
423 | assert result[3]["fields"]["status"]["name"] == "Closed"
424 | # None handling
425 | assert result[3]["fields"].get("assignee") is None
426 |
427 | assert result[4]["key"] == "TEST-5"
428 | assert result[4]["fields"]["summary"] == "Fifth Issue"
429 | assert result[4]["fields"]["status"]["name"] == "Open"
430 |
431 | # Verify V3 client was called the correct number of times with correct parameters
432 | assert mock_v3_client.search_issues.call_count == 3
433 |
434 | # Check first call
435 | first_call = mock_v3_client.search_issues.call_args_list[0]
436 | assert first_call[1]["jql"] == "project = TEST"
437 | assert first_call[1]["start_at"] == 0
438 | assert first_call[1]["max_results"] == 10
439 |
440 | # Check second call
441 | second_call = mock_v3_client.search_issues.call_args_list[1]
442 | assert second_call[1]["jql"] == "project = TEST"
443 | assert second_call[1]["start_at"] == 2 # After first 2 issues
444 | assert second_call[1]["max_results"] == 8 # Remaining needed: 10 - 2 = 8, min(8, 100) = 8
445 |
446 | # Check third call
447 | third_call = mock_v3_client.search_issues.call_args_list[2]
448 | assert third_call[1]["jql"] == "project = TEST"
449 | assert third_call[1]["start_at"] == 4 # After first 4 issues
450 | assert third_call[1]["max_results"] == 6 # Remaining needed: 10 - 4 = 6, min(6, 100) = 6
451 |
452 | @pytest.mark.asyncio
453 | async def test_server_search_issues_pagination_with_limit(self):
454 | """Test JiraServer search_issues method respects max_results when paginating"""
455 | # Mock V3 API responses for multiple pages, but we'll limit results
456 | page1_response = {
457 | "issues": [
458 | {"key": "TEST-1", "fields": {"summary": "First Issue"}},
459 | {"key": "TEST-2", "fields": {"summary": "Second Issue"}},
460 | {"key": "TEST-3", "fields": {"summary": "Third Issue"}}
461 | ],
462 | "startAt": 0,
463 | "maxResults": 3,
464 | "total": 10,
465 | "isLast": False
466 | }
467 |
468 | page2_response = {
469 | "issues": [
470 | {"key": "TEST-4", "fields": {"summary": "Fourth Issue"}},
471 | {"key": "TEST-5", "fields": {"summary": "Fifth Issue"}}
472 | ],
473 | "startAt": 3,
474 | "maxResults": 2, # Only 2 more to reach our limit of 5
475 | "total": 10,
476 | "isLast": False
477 | }
478 |
479 | # Mock V3 API client
480 | mock_v3_client = AsyncMock()
481 | mock_v3_client.search_issues.side_effect = [page1_response, page2_response]
482 |
483 | # Create JiraServer instance and mock the V3 client
484 | server = JiraServer()
485 | server.server_url = "https://test.atlassian.net"
486 | server.username = "testuser"
487 | server.token = "testtoken"
488 |
489 | with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
490 | # Request only 5 results max
491 | result = await server.search_jira_issues("project = TEST", max_results=5)
492 |
493 | # Verify exactly 5 issues were returned (respecting max_results)
494 | assert isinstance(result, list)
495 | assert len(result) == 5
496 | assert result[0]["key"] == "TEST-1"
497 | assert result[1]["key"] == "TEST-2"
498 | assert result[2]["key"] == "TEST-3"
499 | assert result[3]["key"] == "TEST-4"
500 | assert result[4]["key"] == "TEST-5"
501 |
502 | # Verify pagination stopped at the right point
503 | assert mock_v3_client.search_issues.call_count == 2
```