This is page 1 of 3. Use http://codebase.md/infinitiq-tech/mcp-jira?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:
--------------------------------------------------------------------------------
```
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environment variables and secrets
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
*.env
!.env.example
# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
# Logs
logs
*.log
# OS specific
.DS_Store
Thumbs.db
# Test files
test_*.py
!tests/test_*.py
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
# Jira MCP Server - Example Environment File
# Rename this file to .env and update with your Jira credentials
# Required - URL of your Jira instance
JIRA_SERVER_URL=https://your-instance.atlassian.net
# Authentication method ('basic_auth' or 'token_auth')
JIRA_AUTH_METHOD=basic_auth
# For Jira Cloud (basic_auth with API token)
[email protected]
JIRA_TOKEN=your_api_token
# For Jira Server/Data Center (basic_auth with password)
# JIRA_USERNAME=your_username
# JIRA_PASSWORD=your_password
# For Jira Server/Data Center (token_auth with PAT)
# JIRA_AUTH_METHOD=token_auth
# JIRA_TOKEN=your_personal_access_token
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Jira MCP Server
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.
<a href="https://glama.ai/mcp/servers/@InfinitIQ-Tech/mcp-jira">
<img width="380" height="200" src="https://glama.ai/mcp/servers/@InfinitIQ-Tech/mcp-jira/badge" alt="Jira Server MCP server" />
</a>
## Features
- Get all accessible Jira projects
- Get details for a specific Jira issue
- Search issues using JQL (Jira Query Language)
- Create new Jira issues
- Add comments to issues
- Get available transitions for an issue
- Transition issues to new statuses
## Installation
### Prerequisites
- Python 3.9 or higher
- A Jira instance (Cloud, Server, or Data Center)
- [uv](https://github.com/astral-sh/uv) (optional but recommended for dependency management)
### Activate a virtual environment (recommended)
```bash
# Install a Virtual Environment (VENV) in the mcp server directory
python -m venv .
# Activate the virtual environment
source bin/activate
```
### Using uv (recommended)
```
# Install uv if you don't have it
pip install uv
# Install the Jira MCP server
uv pip install mcp-server-jira
```
### Using pip
```bash
pip install mcp-server-jira
```
## Configuration
### Environment Variables
Configure the server using environment variables:
- `JIRA_SERVER_URL`: URL of your Jira server
- `JIRA_AUTH_METHOD`: Authentication method ('basic_auth' or 'token_auth')
- `JIRA_USERNAME`: Username for basic auth
- `JIRA_PASSWORD`: Password for basic auth
- `JIRA_TOKEN`: API token or Personal Access Token
### Environment File (Local Development)
You can also create a `.env` file in the root directory with your configuration:
```
JIRA_SERVER_URL=https://your-jira-instance.atlassian.net
JIRA_AUTH_METHOD=basic_auth
[email protected]
JIRA_TOKEN=your_api_token
```
## Usage
### Command Line
```bash
python -m mcp_server_jira
```
### Docker
```bash
docker build -t mcp-jira .
docker run --env-file .env -p 8080:8080 mcp-jira
```
## Claude Desktop Integration
To use this server with Claude Desktop:
1. Install the server using one of the methods above
2. In Claude Desktop:
- Go to Settings -> Developer
- Click Edit Config
- Open the json configuraiton in your editor of choice
- Add the following JSON:
(`NOTE`: The environment variables used are for token Auth and will not work with other authentication methods)
```json
{
"mcpServers": {
"jira": {
"command": "<PATH TO UV> i.e. /Users/<MYUSERNAME>/.local/bin/uv",
"args": [
"--directory",
"<PATH TO JIRA MCP>",
"run",
"mcp-server-jira"
],
"env": {
"JIRA_SERVER_URL": "https://<ORG>.atlassian.net/",
"JIRA_AUTH_METHOD": "token_auth",
"JIRA_USERNAME": "<USERNAME>",
"JIRA_TOKEN": "<TOKEN>"
}
}
}
}
```
3. Now you can interact with Jira by asking Claude questions like:
- "Show me all my projects in Jira"
- "Get details for issue PROJECT-123"
- "Create a new bug in the PROJECT with summary 'Fix login issue'"
- "Find all open bugs assigned to me"
## Authentication
The server supports multiple authentication methods:
### Basic Authentication
For Jira Server/Data Center with username and password:
```bash
JIRA_SERVER_URL="https://jira.example.com"
JIRA_AUTH_METHOD="basic_auth"
JIRA_USERNAME="your_username"
JIRA_PASSWORD="your_password"
```
### API Token (Jira Cloud)
For Jira Cloud using an API token:
```bash
JIRA_SERVER_URL="https://your-domain.atlassian.net"
JIRA_AUTH_METHOD="basic_auth"
JIRA_USERNAME="[email protected]"
JIRA_TOKEN="your_api_token"
```
### Personal Access Token (Jira Server/Data Center)
For Jira Server/Data Center (8.14+) using a PAT:
```bash
JIRA_SERVER_URL="https://jira.example.com"
JIRA_AUTH_METHOD="token_auth"
JIRA_TOKEN="your_personal_access_token"
```
## Available Tools
1. `get_projects`: Get all accessible Jira projects
2. `get_issue`: Get details for a specific Jira issue by key
3. `search_issues`: Search for Jira issues using JQL
4. `create_issue`: Create a new Jira issue
5. `add_comment`: Add a comment to a Jira issue
6. `get_transitions`: Get available workflow transitions for a Jira issue
7. `transition_issue`: Transition a Jira issue to a new status
## License
MIT
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
# Test package initialization
```
--------------------------------------------------------------------------------
/src/mcp/__init__.py:
--------------------------------------------------------------------------------
```python
# Stub package for mcp modules
```
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
```python
# namespace package for CLI server code
```
--------------------------------------------------------------------------------
/mcp/__init__.py:
--------------------------------------------------------------------------------
```python
"""
Stub package for mcp to satisfy imports in server module.
"""
```
--------------------------------------------------------------------------------
/src/mcp_server_jira/__init__.py:
--------------------------------------------------------------------------------
```python
"""
MCP Server for Jira API integration.
"""
__version__ = "0.1.0"
```
--------------------------------------------------------------------------------
/glama.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": [
"froggomad"
]
}
```
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
```
[pytest]
# Ignore local library packages to avoid import conflicts
addopts = --ignore=lib
# Only look for tests in the tests directory
testpaths = tests
norecursedirs = lib
```
--------------------------------------------------------------------------------
/src/mcp/server/stdio.py:
--------------------------------------------------------------------------------
```python
"""
Stub stdio_server for mcp.server.stdio
"""
def stdio_server(*args, **kwargs):
"""
Stub implementation of the stdio_server function for mcp.server.stdio.
This function accepts any arguments but performs no operations and always returns None.
"""
return None
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
```yaml
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/InfinitIQ-Tech/mcp-jira/blob/main/README.md
about: Check the README for setup and usage instructions
- name: 💬 Discussions
url: https://github.com/InfinitIQ-Tech/mcp-jira/discussions
about: Ask questions and discuss ideas with the community
```
--------------------------------------------------------------------------------
/jira.py:
--------------------------------------------------------------------------------
```python
"""
Stub module for jira.JIRA to satisfy imports in server.
"""
class JIRA:
"""Stub Jira client class"""
def __init__(self, *args, **kwargs):
# Stub initializer
"""
Initializes the JIRA stub instance without performing any setup.
Accepts any positional or keyword arguments for compatibility with code expecting a JIRA client.
"""
pass
```
--------------------------------------------------------------------------------
/src/mcp_server_jira/__main__.py:
--------------------------------------------------------------------------------
```python
import asyncio
import os
from .server import serve
def main() -> None:
# Get configuration from environment variables
server_url = os.environ.get("JIRA_SERVER_URL")
auth_method = os.environ.get("JIRA_AUTH_METHOD")
username = os.environ.get("JIRA_USERNAME")
password = os.environ.get("JIRA_PASSWORD")
token = os.environ.get("JIRA_TOKEN")
asyncio.run(
serve(
server_url=server_url,
auth_method=auth_method,
username=username,
password=password,
token=token,
)
)
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/src/mcp/types/__init__.py:
--------------------------------------------------------------------------------
```python
"""
Stub package for mcp.types to satisfy imports.
"""
from typing import Any, Dict, Sequence, Union
class TextContent:
def __init__(self, type: str, text: str):
"""
Initializes a TextContent instance with a specified type and text.
Args:
type: The type of the text content.
text: The textual content.
"""
self.type = type
self.text = text
class ImageContent:
pass
class EmbeddedResource:
pass
class Tool:
def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
"""
Initializes a Tool instance with a name, description, and input schema.
Args:
name: The name of the tool.
description: A brief description of the tool.
inputSchema: A dictionary defining the expected input schema for the tool.
"""
self.name = name
self.description = description
self.inputSchema = inputSchema
```
--------------------------------------------------------------------------------
/mcp/types/__init__.py:
--------------------------------------------------------------------------------
```python
"""
Stub package for mcp.types to satisfy imports in server module.
"""
from typing import Any, Dict
class TextContent:
def __init__(self, type: str, text: str):
"""
Initializes a TextContent instance with a specified type and text.
Args:
type: The type of the text content.
text: The textual content.
"""
self.type = type
self.text = text
class ImageContent:
pass
class EmbeddedResource:
pass
class Tool:
def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
"""
Initializes a Tool instance with a name, description, and input schema.
Args:
name: The name of the tool.
description: A brief description of the tool's purpose.
inputSchema: A dictionary defining the expected input schema for the tool.
"""
self.name = name
self.description = description
self.inputSchema = inputSchema
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
FROM python:3.10-slim
WORKDIR /app
# Install uv for dependency management
# Note: In production environments, proper SSL certificates should be used
# The --trusted-host flags are included to handle CI/CD environments with certificate issues
RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir uv
# Copy pyproject.toml and uv.lock (if it exists)
COPY pyproject.toml uv.lock* ./
# Copy source code and README (required for package build)
COPY src/ ./src/
COPY README.md ./
# Create and use a virtual environment with uv
# uv provides isolated virtual environments for MCP servers, preventing conflicts with global Python environment
RUN uv venv /opt/venv && \
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 .
# Activate the virtual environment for all subsequent commands
ENV PATH="/opt/venv/bin:$PATH"
# Set the entrypoint
ENTRYPOINT ["python", "-m", "mcp_server_jira"]
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mcp-server-jira"
description = "MCP server for Jira API integration"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
version = "0.1.0"
dependencies = [
"jira>=3.5.0",
"mcp[cli]>=1.0.0",
"pydantic>=2.0.0",
"python-dotenv>=1.0.0",
"httpx>=0.24.0",
]
[project.scripts]
mcp-server-jira = "mcp_server_jira.__main__:main"
[project.optional-dependencies]
dev = [
"pytest",
"black",
"isort",
"mypy",
"uv",
]
[tool.black]
line-length = 88
target-version = ["py310"]
[tool.isort]
profile = "black"
line_length = 88
[tool.pytest]
testpaths = ["tests"]
python_files = "test_*.py"
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false
[dependency-groups]
dev = [
"pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
]
# Remove uv-specific config
```
--------------------------------------------------------------------------------
/mcp/server/__init__.py:
--------------------------------------------------------------------------------
```python
"""
Stub package for mcp.server to satisfy imports in top-level server module.
"""
class Server:
def __init__(self, *args, **kwargs):
"""
Initializes the Server stub with arbitrary arguments.
This constructor accepts any arguments but performs no initialization.
"""
pass
def list_tools(self):
"""
Returns a decorator that leaves the decorated function unchanged.
This method is a stub and does not modify or register the function in any way.
"""
def decorator(fn): return fn
return decorator
def call_tool(self):
"""
Returns a decorator that leaves the decorated function unchanged.
Use this as a placeholder for tool registration in stub implementations.
"""
def decorator(fn): return fn
return decorator
def create_initialization_options(self):
"""
Returns an empty dictionary representing initialization options.
"""
return {}
async def run(self, *args, **kwargs):
"""
Placeholder asynchronous run method with no implementation.
"""
pass
```
--------------------------------------------------------------------------------
/run_server.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Simple script to run the Jira MCP server with detailed logging
"""
import asyncio
import logging
import os
import sys
from src.mcp_server_jira.server import serve
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger()
async def main():
logger.info("Starting Jira MCP server...")
# Get configuration from environment variables
server_url = os.environ.get("JIRA_SERVER_URL")
auth_method = os.environ.get("JIRA_AUTH_METHOD")
username = os.environ.get("JIRA_USERNAME")
password = os.environ.get("JIRA_PASSWORD")
token = os.environ.get("JIRA_TOKEN")
logger.info(f"Server URL: {server_url or 'Not configured'}")
logger.info(f"Auth Method: {auth_method or 'Not configured'}")
try:
await serve(
server_url=server_url,
auth_method=auth_method,
username=username,
password=password,
token=token
)
except Exception as e:
logger.error(f"Error running server: {e}")
raise
if __name__ == "__main__":
asyncio.run(main())
```
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
```markdown
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.
NOTE: There is an ongoing effort to convert from the jira pip package to utilizing the V3 REST API directly.
## Code Standards
### Development Flow
- Build/Run: to ensure compatibilty with the end-user, use `uv` as this is what we recommended.
- `uv run mcp-server-jira`
- Test: to ensure functionality, create and run unit tests using pytest
## Key Guidelines
1. Think like "Uncle Bob" (Robert Martin)
2. Write clean, modular code that follows the Single Responsibility Principle
3. Ensure best-practices with MCP servers are followed by referring to `/MCPReadme.md`
4. Ensure instructions to agents are updated via `@server.list_tools()` - this follows a specific schema so don't be creative with keys.
5. Maintain existing code structure and organization unless otherwise directed
6. Use dependency injection patterns where appropriate
7. Write unit tests for new functionality.
8. Document public APIs and complex logic. Suggest changes to the `docs/` folder when appropriate.
9. Use black, isort, and mypy for code quality
```
--------------------------------------------------------------------------------
/src/mcp/server/__init__.py:
--------------------------------------------------------------------------------
```python
"""
Stub package for mcp.server to satisfy imports.
"""
class Server:
"""Stubbed Server class"""
def __init__(self, *args, **kwargs):
"""
Initializes the Server stub with arbitrary arguments.
This constructor accepts any positional or keyword arguments but does not perform any initialization logic.
"""
pass
def list_tools(self):
"""
Returns a decorator that leaves the decorated function unchanged.
This is a placeholder implementation with no operational effect.
"""
def decorator(fn):
return fn
return decorator
def call_tool(self):
"""
Returns a decorator that leaves the decorated function unchanged.
This is a placeholder implementation with no operational effect.
"""
def decorator(fn):
return fn
return decorator
def create_initialization_options(self):
"""
Returns an empty dictionary representing initialization options.
This stub method provides a placeholder for initialization configuration.
"""
return {}
async def run(self, *args, **kwargs):
"""
Placeholder asynchronous method for running the server.
This stub implementation does not perform any actions.
"""
pass
```
--------------------------------------------------------------------------------
/claude_reference/jira_installation.md:
--------------------------------------------------------------------------------
```markdown
1. Installation
The easiest (and best) way to install jira-python is through pip:
pip install 'jira[cli]'
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.
If you’re going to run the client standalone, we strongly recommend using a virtualenv:
python -m venv jira_python
source jira_python/bin/activate
pip install 'jira[cli]'
or:
python -m venv jira_python
jira_python/bin/pip install 'jira[cli]'
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.
Source packages are also available at PyPI:
https://pypi.python.org/pypi/jira/
1.1. Dependencies
Python >=3.9 is required.
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.
requests-oauthlib - Used to implement OAuth. The latest version as of this writing is 1.3.0.
requests-kerberos - Used to implement Kerberos.
ipython - The IPython enhanced Python interpreter provides the fancy chrome used by Headers.
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.
Installing through pip takes care of these dependencies for you.
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
```yaml
name: Bug Report
description: Report a bug to help us improve
title: "[Bug]: "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: summary
attributes:
label: Summary
description: A brief summary of the bug
placeholder: Describe the bug in one sentence
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what you expected to happen
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: What actually happened instead?
placeholder: Describe what actually happened
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Please provide detailed steps to reproduce the issue
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment Information
description: Please provide details about your environment
placeholder: |
- OS: [e.g. Windows 10, macOS 11.0, Ubuntu 20.04]
- Python Version: [e.g. 3.10.0]
- MCP Server Jira Version: [e.g. 0.1.0]
- Jira Version: [e.g. Cloud, Server 8.x]
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here, including logs, screenshots, etc.
placeholder: Any additional information that might be helpful
validations:
required: false
```
--------------------------------------------------------------------------------
/claude_reference/jira_advanced.txt:
--------------------------------------------------------------------------------
```
4. Advanced
4.1. Resource Objects and Properties
The library distinguishes between two kinds of data in the Jira REST API: resources and properties.
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.
Important
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.
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.
The Jira client’s methods document whether they will return a Resource or a properties object.
```
--------------------------------------------------------------------------------
/bin/activate.fish:
--------------------------------------------------------------------------------
```
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/); you cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV "/Users/kennethdubroff/Development/AI/Vibe/Claude 3.7/claudemcp/servers-main/src/jira"
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) "(jira) " (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT "(jira) "
end
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/story.yml:
--------------------------------------------------------------------------------
```yaml
name: User Story
description: Create a user story for new functionality
title: "[Story]: "
labels: ["story", "enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for creating a user story! Please fill out the sections below to help us understand your requirements.
- type: textarea
id: user-story
attributes:
label: User Story
description: Write your user story in the format "As a [user type], I want [goal] so that [benefit]"
placeholder: |
As a [user type],
I want [goal],
so that [benefit].
validations:
required: true
- type: textarea
id: background
attributes:
label: Background
description: Provide context and background information for this story
placeholder: |
Describe the background, context, and any relevant information that helps understand why this story is needed.
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: Define the specific criteria that must be met for this story to be considered complete
placeholder: |
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
Given [initial context]
When [action taken]
Then [expected result]
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: Any additional notes, considerations, or implementation details
placeholder: |
- Technical considerations
- Dependencies
- Edge cases to consider
- Design considerations
validations:
required: false
- type: textarea
id: non-functional-requirements
attributes:
label: Non-Functional Requirements
description: Specify any non-functional requirements such as performance, security, usability, etc.
placeholder: |
- Performance: [e.g., response time < 2 seconds]
- Security: [e.g., authentication required]
- Usability: [e.g., accessible via keyboard navigation]
- Scalability: [e.g., support up to 1000 concurrent users]
validations:
required: false
- type: dropdown
id: priority
attributes:
label: Priority
description: What is the priority of this story?
options:
- Low
- Medium
- High
- Critical
validations:
required: false
- type: input
id: story-points
attributes:
label: Story Points (estimate)
description: Estimated complexity/effort for this story
placeholder: "e.g., 1, 2, 3, 5, 8, 13"
validations:
required: false
```
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/copilot.yml:
--------------------------------------------------------------------------------
```yaml
name: Copilot Request
description: Request assistance from GitHub Copilot for code generation, review, or optimization
title: "[Copilot]: "
labels: ["copilot", "ai-assistance"]
assignees: ["Copilot"]
body:
- type: markdown
attributes:
value: |
🤖 **Copilot Request Template**
This issue will be automatically assigned to @copilot for AI-powered assistance. Please provide detailed information about what you need help with.
- type: dropdown
id: request-type
attributes:
label: Type of Assistance
description: What kind of help do you need from Copilot?
options:
- Code Generation
- Code Review
- Bug Investigation
- Optimization
- Documentation
- Testing
- Refactoring
- Architecture Advice
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe what you need assistance with
placeholder: |
Provide a clear description of what you need help with. Be as specific as possible.
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: Provide relevant context, existing code, or background information
placeholder: |
- Current implementation (if any)
- Related files or modules
- Constraints or requirements
- Previous attempts or approaches tried
validations:
required: false
- type: textarea
id: requirements
attributes:
label: Requirements & Constraints
description: Specify any specific requirements, constraints, or preferences
placeholder: |
- Language/framework preferences
- Performance requirements
- Coding standards to follow
- Compatibility requirements
- Security considerations
validations:
required: false
- type: textarea
id: expected-outcome
attributes:
label: Expected Outcome
description: What do you expect as the result of this request?
placeholder: |
Describe what you expect to receive:
- Code examples
- Recommendations
- Analysis
- Documentation updates
- Test cases
validations:
required: true
- type: dropdown
id: urgency
attributes:
label: Urgency
description: How urgent is this request?
options:
- Low - When time permits
- Medium - Within a few days
- High - Within 24 hours
- Critical - ASAP
validations:
required: false
- type: textarea
id: additional-info
attributes:
label: Additional Information
description: Any other relevant information, links, or resources
placeholder: |
- Links to documentation
- Related issues or PRs
- External resources
- Screenshots or diagrams
validations:
required: false
```
--------------------------------------------------------------------------------
/claude_reference/jira_shell.txt:
--------------------------------------------------------------------------------
```
3. jirashell
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.
pip install jira[cli]
Run it from the command line
jirashell -s https://jira.atlassian.com
<Jira Shell (https://jira.atlassian.com)>
*** Jira shell active; client is in 'jira'. Press Ctrl-D to exit.
In [1]:
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.
Try getting an issue
In [1]: issue = jira.issue('JRA-1330')
issue now contains a reference to an issue Resource. To see the available properties and methods, hit the TAB key
In [2]: issue.
issue.delete issue.fields issue.id issue.raw issue.update
issue.expand issue.find issue.key issue.self
In [2]: issue.fields.
issue.fields.aggregateprogress issue.fields.customfield_11531
issue.fields.aggregatetimeestimate issue.fields.customfield_11631
issue.fields.aggregatetimeoriginalestimate issue.fields.customfield_11930
issue.fields.aggregatetimespent issue.fields.customfield_12130
issue.fields.assignee issue.fields.customfield_12131
issue.fields.attachment issue.fields.description
issue.fields.comment issue.fields.environment
issue.fields.components issue.fields.fixVersions
issue.fields.created issue.fields.issuelinks
issue.fields.customfield_10150 issue.fields.issuetype
issue.fields.customfield_10160 issue.fields.labels
issue.fields.customfield_10161 issue.fields.mro
issue.fields.customfield_10180 issue.fields.progress
issue.fields.customfield_10230 issue.fields.project
issue.fields.customfield_10575 issue.fields.reporter
issue.fields.customfield_10610 issue.fields.resolution
issue.fields.customfield_10650 issue.fields.resolutiondate
issue.fields.customfield_10651 issue.fields.status
issue.fields.customfield_10680 issue.fields.subtasks
issue.fields.customfield_10723 issue.fields.summary
issue.fields.customfield_11130 issue.fields.timeestimate
issue.fields.customfield_11230 issue.fields.timeoriginalestimate
issue.fields.customfield_11431 issue.fields.timespent
issue.fields.customfield_11433 issue.fields.updated
issue.fields.customfield_11434 issue.fields.versions
issue.fields.customfield_11435 issue.fields.votes
issue.fields.customfield_11436 issue.fields.watches
issue.fields.customfield_11437 issue.fields.workratio
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
name: Task
description: Create a task for work that needs to be done
title: "[Task]: "
labels: ["task"]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for creating a task! Please provide details about the work that needs to be done.
- type: textarea
id: user-story
attributes:
label: User Story (if applicable)
description: If this task relates to a user story, write it in the format "As a [user type], I want [goal] so that [benefit]"
placeholder: |
As a [user type],
I want [goal],
so that [benefit].
(Leave blank if not applicable)
validations:
required: false
- type: textarea
id: task-description
attributes:
label: Task Description
description: Describe what needs to be done
placeholder: |
Provide a clear description of the task to be completed.
validations:
required: true
- type: textarea
id: background
attributes:
label: Background
description: Provide context and background information for this task
placeholder: |
Describe the background, context, and any relevant information that helps understand why this task is needed.
validations:
required: false
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: Define the specific criteria that must be met for this task to be considered complete
placeholder: |
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
The task is complete when:
- [specific outcome 1]
- [specific outcome 2]
validations:
required: true
- type: input
id: parent-issue
attributes:
label: Parent Issue (optional)
description: Link to a parent issue if this task is part of a larger story or epic
placeholder: "#123 or https://github.com/owner/repo/issues/123"
validations:
required: false
- type: textarea
id: notes
attributes:
label: Notes
description: Any additional notes, considerations, or implementation details
placeholder: |
- Technical considerations
- Dependencies
- Implementation approach
- Resources needed
validations:
required: false
- type: textarea
id: non-functional-requirements
attributes:
label: Non-Functional Requirements
description: Specify any non-functional requirements such as performance, security, usability, etc.
placeholder: |
- Performance: [e.g., response time < 2 seconds]
- Security: [e.g., authentication required]
- Usability: [e.g., accessible via keyboard navigation]
- Maintainability: [e.g., code coverage > 80%]
validations:
required: false
- type: dropdown
id: priority
attributes:
label: Priority
description: What is the priority of this task?
options:
- Low
- Medium
- High
- Critical
validations:
required: false
- type: input
id: effort-estimate
attributes:
label: Effort Estimate
description: Estimated time or complexity for this task
placeholder: "e.g., 2 hours, 1 day, 1 week, or story points: 1, 2, 3, 5, 8"
validations:
required: false
```
--------------------------------------------------------------------------------
/docs/create_issues_v3_conversion.md:
--------------------------------------------------------------------------------
```markdown
# Convert create_issues to v3 REST API - Implementation Summary
## Overview
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.
## Changes Made
### 1. JiraV3APIClient - New Method
**File:** `src/mcp_server_jira/jira_v3_api.py`
Added `bulk_create_issues()` method:
- Uses POST `/rest/api/3/issue/bulk` endpoint
- Supports up to 50 issues per API specification
- Takes `issue_updates` array with proper v3 API structure
- Returns response with `issues` and `errors` arrays
### 2. JiraServer - Method Conversion
**File:** `src/mcp_server_jira/server.py`
Converted `create_jira_issues()` method:
- Changed from synchronous to `async` method
- Uses `self._get_v3_api_client()` instead of legacy client
- Maintains existing interface and return format for backward compatibility
- Added ADF (Atlassian Document Format) conversion for descriptions
### 3. Server Integration
**File:** `src/mcp_server_jira/server.py` (line ~1420)
Updated the tool call handler:
- Changed from synchronous to `await` call
- Updated logging messages to reflect async operation
## Key Technical Features
### ADF Conversion
String descriptions are automatically converted to Atlassian Document Format:
```python
# Input: "Simple text description"
# Output:
{
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": "Simple text description"}]
}
]
}
```
### Issue Type Conversion
Maintains existing case conversion logic:
- `"bug"` → `"Bug"`
- `"new feature"` → `"New Feature"`
- `"STORY"` → `"Story"`
- Custom types preserved as-is
### Field Processing
Preserves all existing field processing:
- Project: `"PROJ"` → `{"key": "PROJ"}`
- Labels: `"label"` → `["label"]`
- Assignee: `"user"` → `{"name": "user"}`
### Backward Compatibility
- Supports both `"issue_type"` and `"issuetype"` fields
- Accepts both string and object formats for existing fields
- Returns same result format as legacy implementation
## API Format Transformation
### Input Format (unchanged)
```python
field_list = [
{
"project": "PROJ",
"summary": "Test issue",
"description": "Simple description",
"issue_type": "bug"
}
]
```
### v3 API Format (internal transformation)
```python
{
"issueUpdates": [
{
"fields": {
"project": {"key": "PROJ"},
"summary": "Test issue",
"description": {
"type": "doc",
"version": 1,
"content": [...]
},
"issuetype": {"name": "Bug"}
}
}
]
}
```
### Output Format (unchanged)
```python
[
{
"key": "PROJ-1",
"id": "10000",
"self": "https://...",
"success": True
}
]
```
## Error Handling
- Maintains existing validation for required fields
- Preserves error message format and logging
- Handles v3 API errors with backward-compatible result format
- Supports partial success scenarios (some issues created, some failed)
## Testing
Comprehensive test suite created:
1. **Unit Tests**: `test_bulk_create_issues_v3_api.py`
- v3 API client method testing
- Validation and error handling
2. **Server Tests**: `test_create_jira_issues_server.py`
- Server method integration
- Field conversion and ADF formatting
3. **Integration Tests**: `test_create_issues_integration.py`
- End-to-end workflow testing
- Backward compatibility verification
## Manual Verification
All functionality verified through comprehensive manual testing:
- ✅ Field validation working correctly
- ✅ ADF conversion functioning properly
- ✅ Issue type conversion logic correct
- ✅ Error handling preserved
- ✅ Backward compatibility maintained
## Migration Notes
This change is **fully backward compatible**:
- Existing code using `create_jira_issues()` will continue to work
- Same input format supported
- Same output format returned
- All existing field processing preserved
The only visible change is that the method is now `async` and must be called with `await`.
## Future Maintenance
When maintaining this code:
1. Follow the established v3 API patterns from other converted methods
2. Preserve the ADF conversion for description fields
3. Maintain backward compatibility with existing field formats
4. Use the comprehensive test suite to validate changes
```
--------------------------------------------------------------------------------
/tests/test_create_issue_v3_api_only.py:
--------------------------------------------------------------------------------
```python
"""Test cases for create_issue V3 API client only"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
class TestCreateIssueV3API:
"""Test suite for create_issue V3 API client"""
@pytest.mark.asyncio
async def test_v3_api_create_issue_success(self):
"""Test successful create issue request with V3 API"""
# Mock 201 Created response (standard for successful creation)
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": "10000",
"key": "PROJ-123",
"self": "https://test.atlassian.net/rest/api/3/issue/10000",
}
mock_response.text = '{"id":"10000","key":"PROJ-123","self":"https://test.atlassian.net/rest/api/3/issue/10000"}'
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
# Replace the client instance
client.client = mock_client
fields = {
"project": {"key": "PROJ"},
"summary": "Test issue",
"description": "Test description",
"issuetype": {"name": "Bug"},
}
result = await client.create_issue(fields=fields)
# Verify the response
assert result["id"] == "10000"
assert result["key"] == "PROJ-123"
assert result["self"] == "https://test.atlassian.net/rest/api/3/issue/10000"
# Verify the request was made with correct parameters
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
assert call_args[1]["method"] == "POST"
assert "/rest/api/3/issue" in call_args[1]["url"]
# Verify the payload
payload = call_args[1]["json"]
assert payload["fields"] == fields
@pytest.mark.asyncio
async def test_v3_api_create_issue_with_optional_params(self):
"""Test create issue with optional parameters"""
# Mock 201 Created response
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": "10001",
"key": "PROJ-124",
"self": "https://test.atlassian.net/rest/api/3/issue/10001",
}
mock_response.text = '{"id":"10001","key":"PROJ-124","self":"https://test.atlassian.net/rest/api/3/issue/10001"}'
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
# Replace the client instance
client.client = mock_client
fields = {
"project": {"key": "PROJ"},
"summary": "Test issue with update",
"description": "Test description",
"issuetype": {"name": "Task"},
}
update = {"labels": [{"add": "urgent"}]}
properties = [{"key": "test-property", "value": "test-value"}]
result = await client.create_issue(
fields=fields, update=update, properties=properties
)
# Verify the response
assert result["id"] == "10001"
assert result["key"] == "PROJ-124"
# Verify the request was made with correct parameters
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
# Verify the payload contains all optional parameters
payload = call_args[1]["json"]
assert payload["fields"] == fields
assert payload["update"] == update
assert payload["properties"] == properties
@pytest.mark.asyncio
async def test_v3_api_create_issue_missing_fields(self):
"""Test create issue with missing fields"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="fields is required"):
await client.create_issue(fields=None)
@pytest.mark.asyncio
async def test_v3_api_create_issue_empty_fields(self):
"""Test create issue with empty fields dict"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="fields is required"):
await client.create_issue(fields={})
```
--------------------------------------------------------------------------------
/tests/test_transition_issue_v3_api_only.py:
--------------------------------------------------------------------------------
```python
"""Test cases for transition_issue V3 API client only"""
import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
class TestTransitionIssueV3API:
"""Test suite for transition_issue V3 API client"""
@pytest.mark.asyncio
async def test_v3_api_transition_issue_success(self):
"""Test successful transition issue request with V3 API"""
# Mock 204 No Content response (standard for successful transitions)
mock_response = Mock()
mock_response.status_code = 204
mock_response.json.return_value = {}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
result = await client.transition_issue(
issue_id_or_key="PROJ-123",
transition_id="5"
)
# Verify the request was made correctly
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
assert call_args[1]["method"] == "POST"
assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
assert call_args[1]["json"]["transition"]["id"] == "5"
assert result == {}
@pytest.mark.asyncio
async def test_v3_api_transition_issue_with_comment(self):
"""Test transition issue with comment"""
# Mock 204 No Content response
mock_response = Mock()
mock_response.status_code = 204
mock_response.json.return_value = {}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
result = await client.transition_issue(
issue_id_or_key="PROJ-123",
transition_id="2",
comment="Issue resolved successfully"
)
# Verify the request payload includes properly formatted comment
call_args = mock_client.request.call_args
payload = call_args[1]["json"]
assert payload["transition"]["id"] == "2"
assert "update" in payload
assert "comment" in payload["update"]
assert len(payload["update"]["comment"]) == 1
comment_structure = payload["update"]["comment"][0]["add"]["body"]
assert comment_structure["type"] == "doc"
assert comment_structure["version"] == 1
assert len(comment_structure["content"]) == 1
assert comment_structure["content"][0]["type"] == "paragraph"
assert comment_structure["content"][0]["content"][0]["text"] == "Issue resolved successfully"
@pytest.mark.asyncio
async def test_v3_api_transition_issue_with_fields(self):
"""Test transition issue with field updates"""
# Mock 204 No Content response
mock_response = Mock()
mock_response.status_code = 204
mock_response.json.return_value = {}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
fields = {
"assignee": {"name": "john.doe"},
"resolution": {"name": "Fixed"}
}
result = await client.transition_issue(
issue_id_or_key="PROJ-123",
transition_id="3",
fields=fields
)
# Verify the request payload includes fields
call_args = mock_client.request.call_args
payload = call_args[1]["json"]
assert payload["transition"]["id"] == "3"
assert payload["fields"] == fields
@pytest.mark.asyncio
async def test_v3_api_transition_issue_missing_issue_key(self):
"""Test transition issue with missing issue key"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="issue_id_or_key is required"):
await client.transition_issue("", "5")
@pytest.mark.asyncio
async def test_v3_api_transition_issue_missing_transition_id(self):
"""Test transition issue with missing transition id"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="transition_id is required"):
await client.transition_issue("PROJ-123", "")
```
--------------------------------------------------------------------------------
/tests/test_bulk_create_issues_v3_api.py:
--------------------------------------------------------------------------------
```python
"""Test cases for bulk_create_issues V3 API client only"""
import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
class TestBulkCreateIssuesV3API:
"""Test suite for bulk_create_issues V3 API client"""
@pytest.mark.asyncio
async def test_v3_api_bulk_create_issues_success(self):
"""Test successful bulk create issues request with V3 API"""
# Mock 201 Created response
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
},
{
"id": "10001",
"key": "PROJ-2",
"self": "https://test.atlassian.net/rest/api/3/issue/10001"
}
],
"errors": []
}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
# Test data
issue_updates = [
{
"fields": {
"project": {"key": "PROJ"},
"summary": "First test issue",
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": "Test description"}]
}
]
},
"issuetype": {"name": "Bug"}
}
},
{
"fields": {
"project": {"key": "PROJ"},
"summary": "Second test issue",
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": "Another test description"}]
}
]
},
"issuetype": {"name": "Task"}
}
}
]
result = await client.bulk_create_issues(issue_updates)
# Verify the request was made correctly
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
assert call_args[1]["method"] == "POST"
assert "/rest/api/3/issue/bulk" in call_args[1]["url"]
assert call_args[1]["json"]["issueUpdates"] == issue_updates
# Verify the response
assert "issues" in result
assert "errors" in result
assert len(result["issues"]) == 2
assert result["issues"][0]["key"] == "PROJ-1"
assert result["issues"][1]["key"] == "PROJ-2"
@pytest.mark.asyncio
async def test_v3_api_bulk_create_issues_empty_list(self):
"""Test bulk create issues with empty list"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="issue_updates list cannot be empty"):
await client.bulk_create_issues([])
@pytest.mark.asyncio
async def test_v3_api_bulk_create_issues_too_many(self):
"""Test bulk create issues with too many issues"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Create more than 50 issues
issue_updates = [{"fields": {"project": {"key": "PROJ"}}}] * 51
with pytest.raises(ValueError, match="Cannot create more than 50 issues"):
await client.bulk_create_issues(issue_updates)
@pytest.mark.asyncio
async def test_v3_api_bulk_create_issues_with_errors(self):
"""Test bulk create issues response with some errors"""
# Mock response with partial success
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
}
],
"errors": [
{
"failedElementNumber": 1,
"elementErrors": {
"errorMessages": ["Issue type 'InvalidType' does not exist."]
}
}
]
}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
# Test data
issue_updates = [
{
"fields": {
"project": {"key": "PROJ"},
"summary": "Valid issue",
"issuetype": {"name": "Bug"}
}
},
{
"fields": {
"project": {"key": "PROJ"},
"summary": "Invalid issue",
"issuetype": {"name": "InvalidType"}
}
}
]
result = await client.bulk_create_issues(issue_updates)
# Verify we get both success and error results
assert len(result["issues"]) == 1
assert len(result["errors"]) == 1
assert result["issues"][0]["key"] == "PROJ-1"
assert "errorMessages" in result["errors"][0]["elementErrors"]
```
--------------------------------------------------------------------------------
/tests/test_add_comment_v3_api_only.py:
--------------------------------------------------------------------------------
```python
"""Test cases for add_comment V3 API client only"""
import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
class TestAddCommentV3API:
"""Test suite for add_comment V3 API client"""
@pytest.mark.asyncio
async def test_v3_api_add_comment_success(self):
"""Test successful add comment request with V3 API"""
# Mock successful response
mock_response_data = {
"id": "10000",
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "This is a test comment"
}
]
}
]
},
"author": {
"accountId": "5b10a2844c20165700ede21g",
"displayName": "Test User",
"active": True
},
"created": "2021-01-17T12:34:00.000+0000",
"updated": "2021-01-17T12:34:00.000+0000",
"self": "https://test.atlassian.net/rest/api/3/issue/10010/comment/10000"
}
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = mock_response_data
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
result = await client.add_comment(
issue_id_or_key="PROJ-123",
comment="This is a test comment"
)
# Verify the request was made correctly
call_args = mock_client.request.call_args
assert call_args[1]["method"] == "POST"
assert "https://test.atlassian.net/rest/api/3/issue/PROJ-123/comment" in call_args[1]["url"]
# Verify the request payload
payload = call_args[1]["json"]
assert payload["body"]["type"] == "doc"
assert payload["body"]["version"] == 1
assert len(payload["body"]["content"]) == 1
assert payload["body"]["content"][0]["type"] == "paragraph"
assert payload["body"]["content"][0]["content"][0]["text"] == "This is a test comment"
# Verify the response
assert result == mock_response_data
@pytest.mark.asyncio
async def test_v3_api_add_comment_with_visibility(self):
"""Test add comment with visibility settings"""
# Mock successful response
mock_response_data = {
"id": "10001",
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Internal comment"
}
]
}
]
},
"visibility": {
"type": "role",
"value": "Administrators"
}
}
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = mock_response_data
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
visibility = {"type": "role", "value": "Administrators"}
result = await client.add_comment(
issue_id_or_key="PROJ-456",
comment="Internal comment",
visibility=visibility
)
# Verify the request payload includes visibility
call_args = mock_client.request.call_args
payload = call_args[1]["json"]
assert "visibility" in payload
assert payload["visibility"]["type"] == "role"
assert payload["visibility"]["value"] == "Administrators"
# Verify the response
assert result == mock_response_data
@pytest.mark.asyncio
async def test_v3_api_add_comment_missing_issue_key(self):
"""Test add comment with missing issue key"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="issue_id_or_key is required"):
await client.add_comment(
issue_id_or_key="",
comment="Test comment"
)
@pytest.mark.asyncio
async def test_v3_api_add_comment_missing_comment(self):
"""Test add comment with missing comment text"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="comment is required"):
await client.add_comment(
issue_id_or_key="PROJ-123",
comment=""
)
@pytest.mark.asyncio
async def test_v3_api_add_comment_with_properties(self):
"""Test add comment with properties"""
# Mock successful response
mock_response_data = {
"id": "10002",
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Comment with properties"
}
]
}
]
},
"properties": [
{
"key": "custom-property",
"value": "custom-value"
}
]
}
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = mock_response_data
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
properties = [{"key": "custom-property", "value": "custom-value"}]
result = await client.add_comment(
issue_id_or_key="PROJ-789",
comment="Comment with properties",
properties=properties
)
# Verify the request payload includes properties
call_args = mock_client.request.call_args
payload = call_args[1]["json"]
assert "properties" in payload
assert len(payload["properties"]) == 1
assert payload["properties"][0]["key"] == "custom-property"
assert payload["properties"][0]["value"] == "custom-value"
# Verify the response
assert result == mock_response_data
```
--------------------------------------------------------------------------------
/tests/test_get_transitions_v3.py:
--------------------------------------------------------------------------------
```python
"""Test cases for get_transitions V3 API conversion"""
import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
from src.mcp_server_jira.server import JiraServer, JiraTransitionResult
class TestGetTransitionsV3APIConversion:
"""Test suite for get_transitions V3 API conversion"""
@pytest.mark.asyncio
async def test_v3_api_get_transitions_success(self):
"""Test successful get transitions request with V3 API"""
# Mock response data matching Jira V3 API format
mock_response_data = {
"transitions": [
{
"id": "2",
"name": "Close Issue",
"to": {
"id": "10000",
"name": "Done",
"description": "Issue is done"
},
"hasScreen": False,
"isAvailable": True
},
{
"id": "711",
"name": "QA Review",
"to": {
"id": "5",
"name": "In Review"
},
"hasScreen": True,
"isAvailable": True
}
]
}
# Mock httpx response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
mock_response.text = str(mock_response_data)
mock_response.raise_for_status.return_value = None
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with patch.object(client.client, 'request', new_callable=AsyncMock) as mock_request:
mock_request.return_value = mock_response
result = await client.get_transitions("PROJ-123")
# Verify the result structure
assert "transitions" in result
assert len(result["transitions"]) == 2
assert result["transitions"][0]["id"] == "2"
assert result["transitions"][0]["name"] == "Close Issue"
assert result["transitions"][1]["id"] == "711"
assert result["transitions"][1]["name"] == "QA Review"
# Verify the API call
mock_request.assert_called_once()
call_args = mock_request.call_args
assert call_args[1]["method"] == "GET"
assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
@pytest.mark.asyncio
async def test_v3_api_get_transitions_with_parameters(self):
"""Test get transitions with query parameters"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"transitions": []}
mock_response.text = "{\"transitions\": []}"
mock_response.raise_for_status.return_value = None
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with patch.object(client.client, 'request', new_callable=AsyncMock) as mock_request:
mock_request.return_value = mock_response
await client.get_transitions(
issue_id_or_key="PROJ-123",
expand="transitions.fields",
transition_id="2",
skip_remote_only_condition=True
)
# Verify the request parameters
call_args = mock_request.call_args
params = call_args[1]["params"]
assert params["expand"] == "transitions.fields"
assert params["transitionId"] == "2"
assert params["skipRemoteOnlyCondition"] is True
@pytest.mark.asyncio
async def test_v3_api_get_transitions_missing_issue_key(self):
"""Test get transitions with missing issue key"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="issue_id_or_key is required"):
await client.get_transitions("")
@pytest.mark.asyncio
async def test_jira_server_get_transitions_success(self):
"""Test JiraServer get_jira_transitions method"""
mock_api_response = {
"transitions": [
{"id": "2", "name": "Close Issue"},
{"id": "711", "name": "QA Review"},
{"id": "31", "name": "Reopen Issue"}
]
}
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
mock_get_transitions.return_value = mock_api_response
result = await server.get_jira_transitions("PROJ-123")
# Verify the result type and structure
assert isinstance(result, list)
assert len(result) == 3
assert all(isinstance(t, JiraTransitionResult) for t in result)
# Check specific transition details
assert result[0].id == "2"
assert result[0].name == "Close Issue"
assert result[1].id == "711"
assert result[1].name == "QA Review"
assert result[2].id == "31"
assert result[2].name == "Reopen Issue"
# Verify the V3 API was called correctly
mock_get_transitions.assert_called_once_with(issue_id_or_key="PROJ-123")
@pytest.mark.asyncio
async def test_jira_server_get_transitions_error_handling(self):
"""Test error handling in get_jira_transitions"""
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
mock_get_transitions.side_effect = Exception("API Error")
with pytest.raises(ValueError) as exc_info:
await server.get_jira_transitions("PROJ-123")
assert "Failed to get transitions for PROJ-123" in str(exc_info.value)
assert "API Error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_jira_server_backward_compatibility(self):
"""Test that the new implementation maintains backward compatibility"""
mock_api_response = {
"transitions": [
{"id": "2", "name": "Close Issue"},
{"id": "711", "name": "QA Review"}
]
}
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
mock_get_transitions.return_value = mock_api_response
result = await server.get_jira_transitions("PROJ-123")
# Verify the return type matches the original interface
assert isinstance(result, list)
assert all(isinstance(t, JiraTransitionResult) for t in result)
assert all(hasattr(t, 'id') and hasattr(t, 'name') for t in result)
# Verify specific field types
assert isinstance(result[0].id, str)
assert isinstance(result[0].name, str)
@pytest.mark.asyncio
async def test_jira_server_method_is_async(self):
"""Test that get_jira_transitions is properly converted to async"""
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
import inspect
assert inspect.iscoroutinefunction(server.get_jira_transitions), \
"get_jira_transitions should be an async method"
```
--------------------------------------------------------------------------------
/tests/test_create_issues_integration.py:
--------------------------------------------------------------------------------
```python
"""
Integration test for create_jira_issues V3 API conversion
This test verifies that the conversion from the legacy Jira Python SDK
to the v3 REST API is working correctly for bulk issue creation.
Run with: python -m pytest tests/test_create_issues_integration.py -v
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from src.mcp_server_jira.server import JiraServer
class TestCreateIssuesIntegration:
"""Integration tests for the create_issues v3 API conversion"""
@pytest.mark.asyncio
async def test_full_integration_with_v3_api(self):
"""Test the full integration from server method to v3 API client"""
# Mock successful v3 API response
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
},
{
"id": "10001",
"key": "PROJ-2",
"self": "https://test.atlassian.net/rest/api/3/issue/10001"
}
],
"errors": []
}
# Create mock v3 client
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
# Create server instance
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
# Patch the v3 client creation
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
# Test data representing a typical bulk creation request
field_list = [
{
"project": "PROJ",
"summary": "Implement user login functionality",
"description": "Add OAuth2 login with Google and GitHub providers",
"issue_type": "story",
"labels": ["authentication", "oauth"],
"priority": {"name": "High"}
},
{
"project": "PROJ",
"summary": "Fix mobile navigation bug",
"description": "Navigation menu not displaying on mobile devices",
"issue_type": "bug",
"assignee": {"name": "john.doe"}
}
]
# Execute the method
result = await server.create_jira_issues(field_list, prefetch=True)
# Verify v3 client was called
mock_v3_client.bulk_create_issues.assert_called_once()
# Verify the payload transformation
call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
assert len(call_args) == 2
# Check first issue transformation
issue1 = call_args[0]["fields"]
assert issue1["project"]["key"] == "PROJ"
assert issue1["summary"] == "Implement user login functionality"
assert issue1["issuetype"]["name"] == "Story" # Converted from "story"
assert issue1["labels"] == ["authentication", "oauth"]
assert issue1["priority"] == {"name": "High"}
# Check ADF format for description
assert issue1["description"]["type"] == "doc"
assert "OAuth2 login" in str(issue1["description"])
# Check second issue transformation
issue2 = call_args[1]["fields"]
assert issue2["project"]["key"] == "PROJ"
assert issue2["summary"] == "Fix mobile navigation bug"
assert issue2["issuetype"]["name"] == "Bug" # Converted from "bug"
assert issue2["assignee"] == {"name": "john.doe"}
# Verify return format compatibility
assert len(result) == 2
assert result[0]["key"] == "PROJ-1"
assert result[0]["id"] == "10000"
assert result[0]["success"] is True
assert result[1]["key"] == "PROJ-2"
assert result[1]["success"] is True
@pytest.mark.asyncio
async def test_error_handling_integration(self):
"""Test error handling in the integrated flow"""
# Mock v3 API response with partial errors
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
}
],
"errors": [
{
"failedElementNumber": 1,
"elementErrors": {
"errorMessages": ["Invalid issue type 'InvalidType'"]
}
}
]
}
# Create mock v3 client
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
# Create server instance
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
# Patch the v3 client creation
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
field_list = [
{
"project": "PROJ",
"summary": "Valid issue",
"description": "This should work",
"issue_type": "Bug"
},
{
"project": "PROJ",
"summary": "Invalid issue",
"description": "This should fail",
"issue_type": "InvalidType"
}
]
result = await server.create_jira_issues(field_list)
# Should get both success and error results
assert len(result) == 2
# Find success and error entries
success_results = [r for r in result if r.get("success")]
error_results = [r for r in result if not r.get("success")]
assert len(success_results) == 1
assert len(error_results) == 1
assert success_results[0]["key"] == "PROJ-1"
assert "error" in error_results[0]
@pytest.mark.asyncio
async def test_backward_compatibility_with_legacy_format(self):
"""Test that the method maintains backward compatibility with existing usage"""
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
}
],
"errors": []
}
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
# Test with both new and legacy field formats
field_list = [
{
# Using 'issuetype' field (legacy format)
"project": {"key": "PROJ"}, # Object format
"summary": "Legacy format issue",
"description": "Using legacy field formats",
"issuetype": {"name": "Bug"} # Object format
}
]
result = await server.create_jira_issues(field_list)
# Should work with legacy formats
assert len(result) == 1
assert result[0]["success"] is True
assert result[0]["key"] == "PROJ-1"
# Verify the payload was transformed correctly
call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
issue_fields = call_args[0]["fields"]
# Legacy project object format should be preserved
assert issue_fields["project"]["key"] == "PROJ"
# Legacy issuetype object format should be preserved
assert issue_fields["issuetype"]["name"] == "Bug"
```
--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------
```python
"""
Tests for the main server functionality.
"""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.mcp_server_jira.server import JiraProjectResult, JiraServer
class TestJiraServer:
"""Test suite for JiraServer class"""
def test_init_with_credentials(self):
"""Test JiraServer initialization with credentials"""
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="token",
username="testuser",
token="testtoken",
)
assert server.server_url == "https://test.atlassian.net"
assert server.auth_method == "token"
assert server.username == "testuser"
assert server.token == "testtoken"
@patch.object(JiraServer, "_get_v3_api_client")
def test_get_jira_projects(self, mock_get_v3_api_client):
"""Test getting Jira projects using v3 API"""
# Setup mock v3 client
mock_v3_client = Mock()
mock_v3_client.get_projects.return_value = {
"startAt": 0,
"maxResults": 50,
"total": 1,
"isLast": True,
"values": [
{
"id": "123",
"key": "TEST",
"name": "Test Project",
"lead": {
"displayName": "John Doe"
}
}
]
}
mock_get_v3_api_client.return_value = mock_v3_client
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="token",
username="testuser",
token="testtoken",
)
# Call the method
projects = server.get_jira_projects()
# Verify results
assert len(projects) == 1
assert isinstance(projects[0], JiraProjectResult)
assert projects[0].key == "TEST"
assert projects[0].name == "Test Project"
assert projects[0].id == "123"
assert projects[0].lead == "John Doe"
# Verify v3 client was called correctly
mock_v3_client.get_projects.assert_called_with(
start_at=0,
max_results=50
)
@patch.object(JiraServer, "_get_v3_api_client")
def test_get_jira_projects_pagination(self, mock_get_v3_api_client):
"""Test getting Jira projects with pagination"""
# Setup mock v3 client with pagination
mock_v3_client = Mock()
# First page response
page1_response = {
"startAt": 0,
"maxResults": 2,
"total": 3,
"isLast": False,
"values": [
{"id": "10000", "key": "TEST1", "name": "Test Project 1"},
{"id": "10001", "key": "TEST2", "name": "Test Project 2"}
]
}
# Second page response
page2_response = {
"startAt": 2,
"maxResults": 2,
"total": 3,
"isLast": True,
"values": [
{"id": "10002", "key": "TEST3", "name": "Test Project 3"}
]
}
# Configure mock to return different responses for each call
mock_v3_client.get_projects.side_effect = [page1_response, page2_response]
mock_get_v3_api_client.return_value = mock_v3_client
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="token",
username="testuser",
token="testtoken",
)
# Call the method
projects = server.get_jira_projects()
# Should have called get_projects twice due to pagination
assert mock_v3_client.get_projects.call_count == 2
# Should have collected all 3 projects
assert len(projects) == 3
assert projects[0].key == "TEST1"
assert projects[1].key == "TEST2"
assert projects[2].key == "TEST3"
# Verify correct pagination parameters
calls = mock_v3_client.get_projects.call_args_list
assert calls[0][1]["start_at"] == 0
assert calls[1][1]["start_at"] == 2
@patch.object(JiraServer, "_get_v3_api_client")
def test_create_jira_project_v3_api(self, mock_get_v3_api_client):
"""Test project creation using v3 API"""
# Setup mock v3 client
mock_v3_client = Mock()
mock_v3_client.create_project.return_value = {
"self": "https://test.atlassian.net/rest/api/3/project/10000",
"id": "10000",
"key": "TEST",
"name": "Test Project",
}
mock_get_v3_api_client.return_value = mock_v3_client
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="token",
username="testuser",
token="testtoken",
)
# Call the method
result = server.create_jira_project(
key="TEST", name="Test Project", ptype="software"
)
# Verify results
assert isinstance(result, JiraProjectResult)
assert result.key == "TEST"
assert result.name == "Test Project"
# Verify v3 client was called correctly
mock_v3_client.create_project.assert_called_once_with(
key="TEST",
name="Test Project",
assignee=None,
ptype="software",
template_name=None,
avatarId=None,
issueSecurityScheme=None,
permissionScheme=None,
projectCategory=None,
notificationScheme=None,
categoryId=None,
url="",
)
@patch.object(JiraServer, "_get_v3_api_client")
def test_create_jira_project_with_template(self, mock_get_v3_api_client):
"""Test project creation with template using v3 API"""
# Setup mock v3 client
mock_v3_client = Mock()
mock_v3_client.create_project.return_value = {
"self": "https://test.atlassian.net/rest/api/3/project/10000",
"id": "10000",
"key": "TEMP",
"name": "Template Project",
}
mock_get_v3_api_client.return_value = mock_v3_client
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="token",
username="testuser",
token="testtoken",
)
# Call the method with template
result = server.create_jira_project(
key="TEMP",
name="Template Project",
ptype="business",
template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
assignee="user123",
)
# Verify results
assert isinstance(result, JiraProjectResult)
assert result.key == "TEMP"
assert result.name == "Template Project"
# Verify v3 client was called with template parameters
mock_v3_client.create_project.assert_called_once_with(
key="TEMP",
name="Template Project",
assignee="user123",
ptype="business",
template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
avatarId=None,
issueSecurityScheme=None,
permissionScheme=None,
projectCategory=None,
notificationScheme=None,
categoryId=None,
url="",
)
def test_get_v3_api_client(self):
"""Test v3 client creation"""
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="token",
username="testuser",
token="testtoken",
)
client = server._get_v3_api_client()
assert client.server_url == "https://test.atlassian.net"
assert client.username == "testuser"
assert client.token == "testtoken"
assert client.password is None
def test_get_v3_api_client_with_password(self):
"""Test v3 client creation with password"""
server = JiraServer(
server_url="https://test.atlassian.net",
auth_method="basic",
username="testuser",
password="testpass",
)
client = server._get_v3_api_client()
assert client.server_url == "https://test.atlassian.net"
assert client.username == "testuser"
assert client.password == "testpass"
assert client.token is None
```
--------------------------------------------------------------------------------
/bin/Activate.ps1:
--------------------------------------------------------------------------------
```
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
```
--------------------------------------------------------------------------------
/tests/test_create_jira_issues_server.py:
--------------------------------------------------------------------------------
```python
"""Test cases for create_jira_issues server method using V3 API"""
import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest
from src.mcp_server_jira.server import JiraServer
class TestCreateJiraIssuesServer:
"""Test suite for create_jira_issues server method"""
@pytest.mark.asyncio
async def test_create_jira_issues_server_success(self):
"""Test successful create_jira_issues through server"""
# Mock the v3 API client response
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
},
{
"id": "10001",
"key": "PROJ-2",
"self": "https://test.atlassian.net/rest/api/3/issue/10001"
}
],
"errors": []
}
# Mock the v3 client
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
# Create server instance
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
# Mock the _get_v3_api_client method
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
# Test data
field_list = [
{
"project": "PROJ",
"summary": "First test issue",
"description": "Test description",
"issue_type": "Bug"
},
{
"project": "PROJ",
"summary": "Second test issue",
"description": "Another test description",
"issue_type": "Task"
}
]
result = await server.create_jira_issues(field_list)
# Verify the v3 client was called correctly
mock_v3_client.bulk_create_issues.assert_called_once()
call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
# Check the transformed data structure
assert len(call_args) == 2
assert call_args[0]["fields"]["project"]["key"] == "PROJ"
assert call_args[0]["fields"]["summary"] == "First test issue"
assert call_args[0]["fields"]["issuetype"]["name"] == "Bug"
# Check ADF format for description
assert call_args[0]["fields"]["description"]["type"] == "doc"
assert call_args[0]["fields"]["description"]["version"] == 1
assert "Test description" in str(call_args[0]["fields"]["description"])
# Verify the return format matches the original interface
assert len(result) == 2
assert result[0]["key"] == "PROJ-1"
assert result[0]["id"] == "10000"
assert result[0]["success"] is True
assert result[1]["key"] == "PROJ-2"
assert result[1]["success"] is True
@pytest.mark.asyncio
async def test_create_jira_issues_missing_required_fields(self):
"""Test create_jira_issues with missing required fields"""
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
# Test missing project
with pytest.raises(ValueError, match="Each issue must have a 'project' field"):
await server.create_jira_issues([
{
"summary": "Test issue",
"description": "Test description",
"issue_type": "Bug"
}
])
# Test missing summary
with pytest.raises(ValueError, match="Each issue must have a 'summary' field"):
await server.create_jira_issues([
{
"project": "PROJ",
"description": "Test description",
"issue_type": "Bug"
}
])
# Test missing issue type
with pytest.raises(ValueError, match="Each issue must have an 'issuetype' or 'issue_type' field"):
await server.create_jira_issues([
{
"project": "PROJ",
"summary": "Test issue",
"description": "Test description"
}
])
@pytest.mark.asyncio
async def test_create_jira_issues_issue_type_conversion(self):
"""Test issue type conversion for common types"""
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
}
],
"errors": []
}
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
# Test lowercase issue type conversion
field_list = [
{
"project": "PROJ",
"summary": "Test issue",
"description": "Test description",
"issue_type": "bug" # lowercase
}
]
await server.create_jira_issues(field_list)
# Verify issue type was converted to proper case
call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
assert call_args[0]["fields"]["issuetype"]["name"] == "Bug"
@pytest.mark.asyncio
async def test_create_jira_issues_description_adf_conversion(self):
"""Test that string descriptions are converted to ADF format"""
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
}
],
"errors": []
}
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
field_list = [
{
"project": "PROJ",
"summary": "Test issue",
"description": "Simple text description",
"issue_type": "Bug"
}
]
await server.create_jira_issues(field_list)
# Verify description was converted to ADF format
call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
description = call_args[0]["fields"]["description"]
assert description["type"] == "doc"
assert description["version"] == 1
assert len(description["content"]) == 1
assert description["content"][0]["type"] == "paragraph"
assert description["content"][0]["content"][0]["text"] == "Simple text description"
@pytest.mark.asyncio
async def test_create_jira_issues_with_errors_in_response(self):
"""Test create_jira_issues handling of error responses"""
mock_v3_response = {
"issues": [
{
"id": "10000",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10000"
}
],
"errors": [
{
"failedElementNumber": 1,
"elementErrors": {
"errorMessages": ["Invalid issue type"]
}
}
]
}
mock_v3_client = AsyncMock()
mock_v3_client.bulk_create_issues.return_value = mock_v3_response
server = JiraServer(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass"
)
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
field_list = [
{
"project": "PROJ",
"summary": "Valid issue",
"description": "Valid description",
"issue_type": "Bug"
},
{
"project": "PROJ",
"summary": "Invalid issue",
"description": "Invalid description",
"issue_type": "InvalidType"
}
]
result = await server.create_jira_issues(field_list)
# Should have one success and one error
assert len(result) == 2
# Find success and error results
success_results = [r for r in result if r.get("success")]
error_results = [r for r in result if not r.get("success")]
assert len(success_results) == 1
assert len(error_results) == 1
assert success_results[0]["key"] == "PROJ-1"
assert "error" in error_results[0]
```
--------------------------------------------------------------------------------
/tests/test_jira_v3_api.py:
--------------------------------------------------------------------------------
```python
# pylint: disable=import-error, protected-access
"""
Tests for the Jira v3 API client functionality.
"""
from unittest.mock import Mock, patch
import pytest # pylint: disable=import-error
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
class TestJiraV3APIClient:
"""Test suite for JiraV3APIClient"""
def test_init_with_username_password(self):
"""Test initialization with username and password"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
password="testpass",
)
assert client.server_url == "https://test.atlassian.net"
assert client.username == "testuser"
assert client.password == "testpass"
assert client.token is None
def test_init_with_token(self):
"""Test initialization with token only"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net", token="test-token"
)
assert client.server_url == "https://test.atlassian.net"
assert client.username is None
assert client.password is None
assert client.token == "test-token"
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_make_v3_api_request_success(self, mock_request):
"""Test successful API request"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"key": "TEST", "name": "Test Project"}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
result = client._make_v3_api_request("POST", "/project", {"test": "data"})
assert result == {"key": "TEST", "name": "Test Project"}
mock_request.assert_called_once()
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_make_v3_api_request_error(self, mock_request):
"""Test API request with error response"""
# Setup mock error response
mock_response = Mock()
mock_response.status_code = 400
mock_response.json.return_value = {"errorMessages": ["Bad request"]}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="HTTP 400"):
client._make_v3_api_request("POST", "/project", {"test": "data"})
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_create_project_success(self, mock_request):
"""Test successful project creation"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"self": "https://test.atlassian.net/rest/api/3/project/10000",
"id": "10000",
"key": "TEST",
"name": "Test Project",
}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
result = client.create_project(
key="TEST", name="Test Project", ptype="software"
)
assert result["key"] == "TEST"
assert result["name"] == "Test Project"
mock_request.assert_called_once()
# Verify the request was made with correct data
call_args = mock_request.call_args
assert call_args[1]["method"] == "POST"
assert "/rest/api/3/project" in call_args[1]["url"]
# Check the request body
request_data = call_args[1]["json"]
assert request_data["key"] == "TEST"
assert request_data["name"] == "Test Project"
assert request_data["projectTypeKey"] == "software"
assert request_data["assigneeType"] == "PROJECT_LEAD"
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_create_project_with_template(self, mock_request):
"""Test project creation with template"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"self": "https://test.atlassian.net/rest/api/3/project/10000",
"id": "10000",
"key": "TEMP",
"name": "Template Project",
}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
result = client.create_project(
key="TEMP",
name="Template Project",
ptype="business",
template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
assignee="user123",
)
assert result["key"] == "TEMP"
mock_request.assert_called_once()
# Verify the request data includes template information
call_args = mock_request.call_args
request_data = call_args[1]["json"]
assert (
request_data["projectTemplateKey"]
== "com.atlassian.jira-core-project-templates:jira-core-project-management"
)
assert request_data["leadAccountId"] == "user123"
assert request_data["projectTypeKey"] == "business"
def test_create_project_missing_key(self):
"""Test project creation with missing key"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="Project key is required"):
client.create_project(key="")
def test_create_project_missing_assignee(self):
"""Test project creation with missing assignee"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="Parameter 'assignee'"):
client.create_project(key="TEST")
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_authentication_username_token(self, mock_request):
"""Test authentication with username and token"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"test": "data"}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
client._make_v3_api_request("GET", "/test")
call_args = mock_request.call_args
auth = call_args[1]["auth"]
assert auth == ("testuser", "testtoken")
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_authentication_token_only(self, mock_request):
"""Test authentication with token only"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"test": "data"}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net", token="testtoken"
)
client._make_v3_api_request("GET", "/test")
call_args = mock_request.call_args
headers = call_args[1]["headers"]
assert "Authorization" in headers
assert headers["Authorization"] == "Bearer testtoken"
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_get_projects_success(self, mock_request):
"""Test successful get projects request"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"startAt": 0,
"maxResults": 50,
"total": 2,
"isLast": True,
"values": [
{
"id": "10000",
"key": "TEST",
"name": "Test Project",
"lead": {"displayName": "John Doe"}
},
{
"id": "10001",
"key": "DEMO",
"name": "Demo Project",
"lead": {"displayName": "Jane Smith"}
}
]
}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
result = client.get_projects()
assert result["total"] == 2
assert len(result["values"]) == 2
assert result["values"][0]["key"] == "TEST"
assert result["values"][1]["key"] == "DEMO"
mock_request.assert_called_once()
# Verify the request was made to the correct endpoint
call_args = mock_request.call_args
assert call_args[1]["method"] == "GET"
assert "/rest/api/3/project/search" in call_args[1]["url"]
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_get_projects_with_parameters(self, mock_request):
"""Test get projects with query parameters"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"startAt": 10,
"maxResults": 20,
"total": 50,
"isLast": False,
"values": []
}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
result = client.get_projects(
start_at=10,
max_results=20,
order_by="name",
query="test",
keys=["PROJ1", "PROJ2"]
)
assert result["startAt"] == 10
assert result["maxResults"] == 20
mock_request.assert_called_once()
# Verify the request URL includes query parameters
call_args = mock_request.call_args
url = call_args[1]["url"]
assert "startAt=10" in url
assert "maxResults=20" in url
assert "orderBy=name" in url
assert "query=test" in url
assert "keys=PROJ1,PROJ2" in url
@patch("src.mcp_server_jira.jira_v3_api.requests.request")
def test_get_projects_error(self, mock_request):
"""Test get projects with error response"""
# Setup mock error response
mock_response = Mock()
mock_response.status_code = 401
mock_response.json.return_value = {"errorMessages": ["Unauthorized"]}
mock_request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="HTTP 401"):
client.get_projects()
@patch("src.mcp_server_jira.jira_v3_api.httpx.AsyncClient")
async def test_get_transitions_success(self, mock_async_client):
"""Test successful get transitions request"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"transitions": [
{
"id": "2",
"name": "Close Issue",
"to": {
"id": "10000",
"name": "Done",
"description": "Issue is done"
},
"hasScreen": False,
"isAvailable": True,
"isConditional": False,
"isGlobal": False,
"isInitial": False
},
{
"id": "711",
"name": "QA Review",
"to": {
"id": "5",
"name": "In Review",
"description": "Issue is under review"
},
"hasScreen": True,
"isAvailable": True,
"isConditional": False,
"isGlobal": False,
"isInitial": False
}
]
}
mock_response.raise_for_status.return_value = None
# Setup mock client
mock_client_instance = Mock()
mock_client_instance.request.return_value = mock_response
mock_async_client.return_value = mock_client_instance
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
result = await client.get_transitions("PROJ-123")
assert "transitions" in result
assert len(result["transitions"]) == 2
assert result["transitions"][0]["id"] == "2"
assert result["transitions"][0]["name"] == "Close Issue"
assert result["transitions"][1]["id"] == "711"
assert result["transitions"][1]["name"] == "QA Review"
# Verify the request was made with correct parameters
mock_client_instance.request.assert_called_once()
call_args = mock_client_instance.request.call_args
assert call_args[1]["method"] == "GET"
assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
@patch("src.mcp_server_jira.jira_v3_api.httpx.AsyncClient")
async def test_get_transitions_with_parameters(self, mock_async_client):
"""Test get transitions with query parameters"""
# Setup mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"transitions": []}
mock_response.raise_for_status.return_value = None
# Setup mock client
mock_client_instance = Mock()
mock_client_instance.request.return_value = mock_response
mock_async_client.return_value = mock_client_instance
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
await client.get_transitions(
issue_id_or_key="PROJ-123",
expand="transitions.fields",
transition_id="2",
skip_remote_only_condition=True,
include_unavailable_transitions=False,
sort_by_ops_bar_and_status=True
)
# Verify the request was made with correct parameters
mock_client_instance.request.assert_called_once()
call_args = mock_client_instance.request.call_args
assert call_args[1]["method"] == "GET"
params = call_args[1]["params"]
assert params["expand"] == "transitions.fields"
assert params["transitionId"] == "2"
assert params["skipRemoteOnlyCondition"] is True
assert params["includeUnavailableTransitions"] is False
assert params["sortByOpsBarAndStatus"] is True
async def test_get_transitions_missing_issue_key(self):
"""Test get transitions with missing issue key"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken",
)
with pytest.raises(ValueError, match="issue_id_or_key is required"):
await client.get_transitions("")
```
--------------------------------------------------------------------------------
/claude_reference/jira_examples.txt:
--------------------------------------------------------------------------------
```
2. Examples
Here’s a quick usage example:
# This script shows how to use the client in anonymous mode
# against jira.atlassian.com.
from __future__ import annotations
import re
from jira import JIRA
# By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK
# (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details).
jira = JIRA(server="https://jira.atlassian.com")
# Get all projects viewable by anonymous users.
projects = jira.projects()
# Sort available project keys, then return the second, third, and fourth keys.
keys = sorted(project.key for project in projects)[2:5]
# Get an issue.
issue = jira.issue("JRA-1330")
# Find all comments made by Atlassians on this issue.
atl_comments = [
comment
for comment in issue.fields.comment.comments
if re.search(r"@atlassian.com$", comment.author.key)
]
# Add a comment to the issue.
jira.add_comment(issue, "Comment text")
# Change the issue's summary and description.
issue.update(
summary="I'm different!", description="Changed the summary to be different."
)
# Change the issue without sending updates
issue.update(notify=False, description="Quiet summary update.")
# You can update the entire labels field like this
issue.update(fields={"labels": ["AAA", "BBB"]})
# Or modify the List of existing labels. The new label is unicode with no
# spaces
issue.fields.labels.append("new_text")
issue.update(fields={"labels": issue.fields.labels})
# Send the issue away for good.
issue.delete()
# Linking a remote jira issue (needs applinks to be configured to work)
issue = jira.issue("JRA-1330")
issue2 = jira.issue("XX-23") # could also be another instance
jira.add_remote_link(issue.id, issue2)
Another example with methods to authenticate with your Jira:
"""Some simple authentication examples."""
from __future__ import annotations
from collections import Counter
from jira import JIRA
from jira.client import ResultList
from jira.resources import Issue
# Some Authentication Methods
jira = JIRA(
basic_auth=("admin", "admin"), # a username/password tuple [Not recommended]
# basic_auth=("email", "API token"), # Jira Cloud: a username/token tuple
# token_auth="API token", # Self-Hosted Jira (e.g. Server): the PAT token
# auth=("admin", "admin"), # a username/password tuple for cookie auth [Not recommended]
)
# Who has authenticated
myself = jira.myself()
# Get the mutable application properties for this server (requires
# jira-system-administrators permission)
props = jira.application_properties()
# Find all issues reported by the admin
issues: ResultList[Issue] = jira.search_issues("assignee=admin")
# Find the top three projects containing issues reported by admin
top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3)
This example shows how to work with Jira Agile / Jira Software (formerly GreenHopper):
# This script shows how to use the client in anonymous mode
# against jira.atlassian.com.
from __future__ import annotations
from jira.client import JIRA
# By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK
# (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details).
# Override this with the options parameter.
jira = JIRA(server="https://jira.atlassian.com")
# Get all boards viewable by anonymous users.
boards = jira.boards()
# Get the sprints in a specific board
board_id = 441
print(f"JIRA board: {boards[0].name} ({board_id})")
sprints = jira.sprints(board_id)
2.1. Quickstart
2.1.1. Initialization
Everything goes through the jira.client.JIRA object, so make one:
from jira import JIRA
jira = JIRA()
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.
You can manually set the Jira server to use:
jira = JIRA('https://jira.atlassian.com')
2.1.2. Authentication
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.
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.
2.1.2.1. Cookie Based Authentication
Warning
This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice here.
For Jira Cloud use the basic_auth= (username, api_token) authentication
Pass a tuple of (username, password) to the auth constructor argument:
auth_jira = JIRA(auth=('username', 'password'))
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.
2.1.2.2. HTTP BASIC
2.1.2.2.1. (username, password)
Warning
This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice here
For Jira Cloud use the basic_auth= (username, api_token) authentication. For Self Hosted Jira (Server, Data Center), consider the Token Auth authentication.
Pass a tuple of (username, password) to the basic_auth constructor argument:
auth_jira = JIRA(basic_auth=('username', 'password'))
2.1.2.2.2. (username, api_token)
Or pass a tuple of (email, api_token) to the basic_auth constructor argument (JIRA Cloud):
auth_jira = JIRA(basic_auth=('email', 'API token'))
See also
For Self Hosted Jira (Server, Data Center), refer to the Token Auth Section.
2.1.2.3. OAuth
Pass a dict of OAuth properties to the oauth constructor argument:
# all values are samples and won't work in your code!
key_cert_data = None
with open(key_cert, 'r') as key_cert_file:
key_cert_data = key_cert_file.read()
oauth_dict = {
'access_token': 'foo',
'access_token_secret': 'bar',
'consumer_key': 'jira-oauth-consumer',
'key_cert': key_cert_data
}
auth_jira = JIRA(oauth=oauth_dict)
Note
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.
The access token and token secret uniquely identify the user.
The consumer key must match the OAuth provider configured on the Jira server.
The key cert data must be the private key that matches the public key configured on the Jira server’s OAuth provider.
See https://confluence.atlassian.com/display/JIRA/Configuring+OAuth+Authentication+for+an+Application+Link for details on configuring an OAuth provider for Jira.
2.1.2.4. Token Auth
2.1.2.4.1. Jira Cloud
This is also referred to as an API Token in the Jira Cloud documentation
auth_jira = JIRA(basic_auth=('email', 'API token'))
2.1.2.4.2. Jira Self Hosted (incl. Jira Server/Data Center)
This is also referred to as Personal Access Tokens (PATs) in the Self-Hosted Documentation. The is available from Jira Core >= 8.14:
auth_jira = JIRA(token_auth='API token')
2.1.2.5. Kerberos
To enable Kerberos auth, set kerberos=True:
auth_jira = JIRA(kerberos=True)
To pass additional options to Kerberos auth use dict kerberos_options, e.g.:
auth_jira = JIRA(kerberos=True, kerberos_options={'mutual_authentication': 'DISABLED'})
2.1.3. Headers
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.
For example if you want to use a custom User Agent:
from requests_toolbelt import user_agent
jira = JIRA(
basic_auth=("email", "API token"),
options={"headers": {"User-Agent": user_agent("my_package", "0.0.1")}},
)
2.1.4. Issues
Issues are objects. You get hold of them through the JIRA object:
issue = jira.issue('JRA-1330')
Issue JSON is marshaled automatically and used to augment the returned Issue object, so you can get direct access to fields:
summary = issue.fields.summary # 'Field level security permissions'
votes = issue.fields.votes.votes # 440 (at least)
If you only want a few specific fields, save time by asking for them explicitly:
issue = jira.issue('JRA-1330', fields='summary,comment')
Reassign an issue:
# requires issue assign permission, which is different from issue editing permission!
jira.assign_issue(issue, 'newassignee')
If you want to unassign it again, just do:
jira.assign_issue(issue, None)
Creating issues is easy:
new_issue = jira.create_issue(project='PROJ_key_or_id', summary='New issue from jira-python',
description='Look into this one', issuetype={'name': 'Bug'})
Or you can use a dict:
issue_dict = {
'project': {'id': 123},
'summary': 'New issue from jira-python',
'description': 'Look into this one',
'issuetype': {'name': 'Bug'},
}
new_issue = jira.create_issue(fields=issue_dict)
You can even bulk create multiple issues:
issue_list = [
{
'project': {'id': 123},
'summary': 'First issue of many',
'description': 'Look into this one',
'issuetype': {'name': 'Bug'},
},
{
'project': {'key': 'FOO'},
'summary': 'Second issue',
'description': 'Another one',
'issuetype': {'name': 'Bug'},
},
{
'project': {'name': 'Bar'},
'summary': 'Last issue',
'description': 'Final issue of batch.',
'issuetype': {'name': 'Bug'},
}]
issues = jira.create_issues(field_list=issue_list)
Note
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.
Note
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.
You can also update an issue’s fields with keyword arguments:
issue.update(summary='new summary', description='A new summary was added')
issue.update(assignee={'name': 'new_user'}) # reassigning in update requires issue edit permission
or with a dict of new field values:
issue.update(fields={'summary': 'new summary', 'description': 'A new summary was added'})
You can suppress notifications:
issue.update(notify=False, description='A quiet description change was made')
and when you’re done with an issue, you can send it to the great hard drive in the sky:
issue.delete()
Updating components:
existingComponents = []
for component in issue.fields.components:
existingComponents.append({"name" : component.name})
issue.update(fields={"components": existingComponents})
2.1.4.1. Working with Rich Text
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):
jira = JIRA(basic_auth=("email", "API token"))
comment = {
"type": "doc",
"version": 1,
"content": [
{
"type": "codeBlock",
"content": [
{
"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.",
"type": "text"
}
]
}
]
}
jira.add_comment("AB-123", comment)
2.1.5. Fields
Example for accessing the worklogs:
issue.fields.worklog.worklogs # list of Worklog objects
issue.fields.worklog.worklogs[0].author
issue.fields.worklog.worklogs[0].comment
issue.fields.worklog.worklogs[0].created
issue.fields.worklog.worklogs[0].id
issue.fields.worklog.worklogs[0].self
issue.fields.worklog.worklogs[0].started
issue.fields.worklog.worklogs[0].timeSpent
issue.fields.worklog.worklogs[0].timeSpentSeconds
issue.fields.worklog.worklogs[0].updateAuthor # dictionary
issue.fields.worklog.worklogs[0].updated
issue.fields.timetracking.remainingEstimate # may be NULL or string ("0m", "2h"...)
issue.fields.timetracking.remainingEstimateSeconds # may be NULL or integer
issue.fields.timetracking.timeSpent # may be NULL or string
issue.fields.timetracking.timeSpentSeconds # may be NULL or integer
2.1.6. Searching
Leverage the power of JQL to quickly find the issues you want:
# Search returns first 50 results, `maxResults` must be set to exceed this
issues_in_proj = jira.search_issues('project=PROJ')
all_proj_issues_but_mine = jira.search_issues('project=PROJ and assignee != currentUser()')
# my top 5 issues due by the end of the week, ordered by priority
oh_crap = jira.search_issues('assignee = currentUser() and due < endOfWeek() order by priority desc', maxResults=5)
# Summaries of my last 3 reported issues
for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3):
print('{}: {}'.format(issue.key, issue.fields.summary))
2.1.7. Comments
Comments, like issues, are objects. Access issue comments through the parent Issue object or the JIRA object’s dedicated method:
comments_a = issue.fields.comment.comments
comments_b = jira.comments(issue) # comments_b == comments_a
Obtain an individual comment if you know its ID:
comment = jira.comment('JRA-1330', '10234')
Obtain comment author name and comment creation timestamp if you know its ID:
author = jira.comment('JRA-1330', '10234').author.displayName
time = jira.comment('JRA-1330', '10234').created
Adding, editing and deleting comments is similarly straightforward:
comment = jira.add_comment('JRA-1330', 'new comment') # no Issue object required
comment = jira.add_comment(issue, 'new comment', visibility={'type': 'role', 'value': 'Administrators'}) # for admins only
comment.update(body='updated comment body')
comment.update(body='updated comment body but no mail notification', notify=False)
comment.delete()
Get all images from a comment:
issue = jira.issue('JRA-1330')
regex_for_png = re.compile(r'\!(\S+?\.(jpg|png|bmp))\|?\S*?\!')
pngs_used_in_comment = regex_for_png.findall(issue.fields.comment.comments[0].body)
for attachment in issue.fields.attachment:
if attachment.filename in pngs_used_in_comment:
with open(attachment.filename, 'wb') as f:
f.write(attachment.get())
2.1.8. Transitions
Learn what transitions are available on an issue:
issue = jira.issue('PROJ-1')
transitions = jira.transitions(issue)
[(t['id'], t['name']) for t in transitions] # [(u'5', u'Resolve Issue'), (u'2', u'Close Issue')]
Note
Only the transitions available to the currently authenticated user will be returned!
Then perform a transition on an issue:
# Resolve the issue and assign it to 'pm_user' in one step
jira.transition_issue(issue, '5', assignee={'name': 'pm_user'}, resolution={'id': '3'})
# The above line is equivalent to:
jira.transition_issue(issue, '5', fields={'assignee':{'name': 'pm_user'}, 'resolution':{'id': '3'}})
2.1.9. Projects
Projects are objects, just like issues:
projects = jira.projects()
Also, just like issue objects, project objects are augmented with their fields:
jra = jira.project('JRA')
print(jra.name) # 'JIRA'
print(jra.lead.displayName) # 'John Doe [ACME Inc.]'
It’s no trouble to get the components, versions or roles either (assuming you have permission):
components = jira.project_components(jra)
[c.name for c in components] # 'Accessibility', 'Activity Stream', 'Administration', etc.
jira.project_roles(jra) # 'Administrators', 'Developers', etc.
versions = jira.project_versions(jra)
[v.name for v in reversed(versions)] # '5.1.1', '5.1', '5.0.7', '5.0.6', etc.
2.1.10. Watchers
Watchers are objects, represented by jira.resources.Watchers:
watcher = jira.watchers(issue)
print("Issue has {} watcher(s)".format(watcher.watchCount))
for watcher in watcher.watchers:
print(watcher)
# watcher is instance of jira.resources.User:
print(watcher.emailAddress)
You can add users to watchers by their name:
jira.add_watcher(issue, 'username')
jira.add_watcher(issue, user_resource.name)
And of course you can remove users from watcher:
jira.remove_watcher(issue, 'username')
jira.remove_watcher(issue, user_resource.name)
2.1.11. Attachments
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:
# upload file from `/some/path/attachment.txt`
jira.add_attachment(issue=issue, attachment='/some/path/attachment.txt')
# read and upload a file (note binary mode for opening, it's important):
with open('/some/path/attachment.txt', 'rb') as f:
jira.add_attachment(issue=issue, attachment=f)
# attach file from memory (you can skip IO operations). In this case you MUST provide `filename`.
from io import StringIO
attachment = StringIO()
attachment.write(data)
jira.add_attachment(issue=issue, attachment=attachment, filename='content.txt')
If you would like to list all available attachment, you can do it with through attachment field:
for attachment in issue.fields.attachment:
print("Name: '{filename}', size: {size}".format(
filename=attachment.filename, size=attachment.size))
# to read content use `get` method:
print("Content: '{}'".format(attachment.get()))
You can delete attachment by id:
# Find issues with attachments:
query = jira.search_issues(jql_str="attachments is not EMPTY", json_result=True, fields="key, attachment")
# And remove attachments one by one
for i in query['issues']:
for a in i['fields']['attachment']:
print("For issue {0}, found attach: '{1}' [{2}].".format(i['key'], a['filename'], a['id']))
jira.delete_attachment(a['id'])
```
--------------------------------------------------------------------------------
/tests/test_search_issues_v3_api.py:
--------------------------------------------------------------------------------
```python
"""Test cases for search_issues V3 API client and server integration"""
import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest
from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
from src.mcp_server_jira.server import JiraServer, JiraIssueResult
class TestSearchIssuesV3API:
"""Test suite for search_issues V3 API client"""
@pytest.mark.asyncio
async def test_v3_api_search_issues_success(self):
"""Test successful search issues request with V3 API"""
# Mock successful search response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"issues": [
{
"key": "PROJ-123",
"fields": {
"summary": "Test issue summary",
"description": "Test issue description",
"status": {"name": "Open"},
"assignee": {"displayName": "John Doe"},
"reporter": {"displayName": "Jane Smith"},
"created": "2023-01-01T00:00:00.000+0000",
"updated": "2023-01-02T00:00:00.000+0000"
}
},
{
"key": "PROJ-124",
"fields": {
"summary": "Another test issue",
"description": "Another description",
"status": {"name": "In Progress"},
"assignee": None,
"reporter": {"displayName": "Bob Wilson"},
"created": "2023-01-03T00:00:00.000+0000",
"updated": "2023-01-04T00:00:00.000+0000"
}
}
],
"startAt": 0,
"maxResults": 50,
"total": 2,
"isLast": True
}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
result = await client.search_issues(
jql="project = PROJ",
max_results=10
)
# Verify the request was made correctly
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
assert call_args[1]["method"] == "GET"
assert call_args[1]["url"] == "https://test.atlassian.net/rest/api/3/search/jql"
assert call_args[1]["params"]["jql"] == "project = PROJ"
assert call_args[1]["params"]["maxResults"] == 10
# Verify response
assert result["total"] == 2
assert len(result["issues"]) == 2
assert result["issues"][0]["key"] == "PROJ-123"
@pytest.mark.asyncio
async def test_v3_api_search_issues_with_parameters(self):
"""Test search issues with optional parameters"""
# Mock successful search response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"issues": [],
"startAt": 0,
"maxResults": 25,
"total": 0,
"isLast": True
}
mock_response.text = ""
mock_response.raise_for_status.return_value = None
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.return_value = mock_response
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
result = await client.search_issues(
jql="project = PROJ AND status = Open",
start_at=10,
max_results=25,
fields="summary,status,assignee",
expand="changelog"
)
# Verify the request was made correctly
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args
assert call_args[1]["method"] == "GET"
assert call_args[1]["url"] == "https://test.atlassian.net/rest/api/3/search/jql"
params = call_args[1]["params"]
assert params["jql"] == "project = PROJ AND status = Open"
assert params["startAt"] == 10
assert params["maxResults"] == 25
assert params["fields"] == "summary,status,assignee"
assert params["expand"] == "changelog"
@pytest.mark.asyncio
async def test_v3_api_search_issues_missing_jql(self):
"""Test search issues with missing JQL parameter"""
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
with pytest.raises(ValueError, match="jql parameter is required"):
await client.search_issues("")
@pytest.mark.asyncio
async def test_v3_api_search_issues_api_error(self):
"""Test search issues with API error response"""
# Mock error response
mock_response = Mock()
mock_response.status_code = 400
mock_response.reason_phrase = "Bad Request"
mock_response.json.return_value = {"errorMessages": ["Invalid JQL"]}
from httpx import HTTPStatusError, Request, Response
mock_request = Mock(spec=Request)
mock_request.url = "https://test.atlassian.net/rest/api/3/search/jql"
# Mock httpx client
mock_client = AsyncMock()
mock_client.request.side_effect = HTTPStatusError(
"400 Bad Request", request=mock_request, response=mock_response
)
client = JiraV3APIClient(
server_url="https://test.atlassian.net",
username="testuser",
token="testtoken"
)
# Replace the client instance
client.client = mock_client
with pytest.raises(ValueError, match="Jira API returned an error: 400"):
await client.search_issues(jql="invalid jql syntax")
class TestSearchIssuesJiraServer:
"""Test suite for search_issues in JiraServer class"""
@pytest.mark.asyncio
async def test_server_search_issues_success(self):
"""Test JiraServer search_issues method with successful V3 API response"""
# Mock V3 API response
mock_v3_response = {
"issues": [
{
"key": "TEST-1",
"fields": {
"summary": "Test Summary",
"description": "Test Description",
"status": {"name": "Open"},
"assignee": {"displayName": "Test User"},
"reporter": {"displayName": "Reporter User"},
"created": "2023-01-01T00:00:00.000+0000",
"updated": "2023-01-02T00:00:00.000+0000"
}
}
]
}
# Mock V3 API client
mock_v3_client = AsyncMock()
mock_v3_client.search_issues.return_value = mock_v3_response
# Create JiraServer instance and mock the V3 client
server = JiraServer()
server.server_url = "https://test.atlassian.net"
server.username = "testuser"
server.token = "testtoken"
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
result = await server.search_jira_issues("project = TEST", max_results=10)
# Verify the result
assert isinstance(result, list)
assert len(result) == 1
issue = result[0]
assert isinstance(issue, dict)
assert issue["key"] == "TEST-1"
assert issue["fields"]["summary"] == "Test Summary"
assert issue["fields"]["description"] == "Test Description"
assert issue["fields"]["status"]["name"] == "Open"
assert issue["fields"]["assignee"]["displayName"] == "Test User"
assert issue["fields"]["reporter"]["displayName"] == "Reporter User"
assert issue["fields"]["created"] == "2023-01-01T00:00:00.000+0000"
assert issue["fields"]["updated"] == "2023-01-02T00:00:00.000+0000"
# Verify V3 client was called correctly
mock_v3_client.search_issues.assert_called_once_with(
jql="project = TEST", start_at=0, max_results=10
)
@pytest.mark.asyncio
async def test_server_search_issues_handles_missing_fields(self):
"""Test JiraServer search_issues method handles missing optional fields gracefully"""
# Mock V3 API response with minimal data
mock_v3_response = {
"issues": [
{
"key": "TEST-2",
"fields": {
"summary": "Basic Summary",
# Missing description, status, assignee, reporter, etc.
}
}
]
}
# Mock V3 API client
mock_v3_client = AsyncMock()
mock_v3_client.search_issues.return_value = mock_v3_response
# Create JiraServer instance and mock the V3 client
server = JiraServer()
server.server_url = "https://test.atlassian.net"
server.username = "testuser"
server.token = "testtoken"
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
result = await server.search_jira_issues("project = TEST")
# Verify the result handles missing fields gracefully
assert isinstance(result, list)
assert len(result) == 1
issue = result[0]
assert isinstance(issue, dict)
assert issue["key"] == "TEST-2"
assert issue["fields"]["summary"] == "Basic Summary"
# Missing description, status, assignee, reporter should be absent or None
assert issue["fields"].get("description") is None
assert issue["fields"].get("status") is None
assert issue["fields"].get("assignee") is None
assert issue["fields"].get("reporter") is None
@pytest.mark.asyncio
async def test_server_search_issues_api_error(self):
"""Test JiraServer search_issues method with API error"""
# Mock V3 API client that raises an error
mock_v3_client = AsyncMock()
mock_v3_client.search_issues.side_effect = ValueError("API connection failed")
# Create JiraServer instance and mock the V3 client
server = JiraServer()
server.server_url = "https://test.atlassian.net"
server.username = "testuser"
server.token = "testtoken"
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
with pytest.raises(ValueError, match="Failed to search issues"):
await server.search_jira_issues("project = TEST")
@pytest.mark.asyncio
async def test_server_search_issues_pagination(self):
"""Test JiraServer search_issues method handles pagination correctly"""
# Mock V3 API responses for pagination
# First page response
page1_response = {
"issues": [
{
"key": "TEST-1",
"fields": {
"summary": "First Issue",
"description": "First Description",
"status": {"name": "Open"},
"assignee": {"displayName": "User 1"},
"reporter": {"displayName": "Reporter 1"},
"created": "2023-01-01T00:00:00.000+0000",
"updated": "2023-01-01T00:00:00.000+0000"
}
},
{
"key": "TEST-2",
"fields": {
"summary": "Second Issue",
"description": "Second Description",
"status": {"name": "In Progress"},
"assignee": {"displayName": "User 2"},
"reporter": {"displayName": "Reporter 2"},
"created": "2023-01-02T00:00:00.000+0000",
"updated": "2023-01-02T00:00:00.000+0000"
}
}
],
"startAt": 0,
"maxResults": 2,
"total": 5,
"isLast": False
}
# Second page response
page2_response = {
"issues": [
{
"key": "TEST-3",
"fields": {
"summary": "Third Issue",
"description": "Third Description",
"status": {"name": "Done"},
"assignee": {"displayName": "User 3"},
"reporter": {"displayName": "Reporter 3"},
"created": "2023-01-03T00:00:00.000+0000",
"updated": "2023-01-03T00:00:00.000+0000"
}
},
{
"key": "TEST-4",
"fields": {
"summary": "Fourth Issue",
"description": "Fourth Description",
"status": {"name": "Closed"},
"assignee": None,
"reporter": {"displayName": "Reporter 4"},
"created": "2023-01-04T00:00:00.000+0000",
"updated": "2023-01-04T00:00:00.000+0000"
}
}
],
"startAt": 2,
"maxResults": 2,
"total": 5,
"isLast": False
}
# Third page response
page3_response = {
"issues": [
{
"key": "TEST-5",
"fields": {
"summary": "Fifth Issue",
"description": "Fifth Description",
"status": {"name": "Open"},
"assignee": {"displayName": "User 5"},
"reporter": {"displayName": "Reporter 5"},
"created": "2023-01-05T00:00:00.000+0000",
"updated": "2023-01-05T00:00:00.000+0000"
}
}
],
"startAt": 4,
"maxResults": 2,
"total": 5,
"isLast": True
}
# Mock V3 API client with side_effect to return different pages
mock_v3_client = AsyncMock()
mock_v3_client.search_issues.side_effect = [page1_response, page2_response, page3_response]
# Create JiraServer instance and mock the V3 client
server = JiraServer()
server.server_url = "https://test.atlassian.net"
server.username = "testuser"
server.token = "testtoken"
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
result = await server.search_jira_issues("project = TEST", max_results=10)
# Verify all issues from all pages were retrieved
assert isinstance(result, list)
assert len(result) == 5
# Check each issue dict
assert isinstance(result[0], dict)
assert result[0]["key"] == "TEST-1"
assert result[0]["fields"]["summary"] == "First Issue"
assert result[0]["fields"]["status"]["name"] == "Open"
assert result[1]["key"] == "TEST-2"
assert result[1]["fields"]["summary"] == "Second Issue"
assert result[1]["fields"]["status"]["name"] == "In Progress"
assert result[2]["key"] == "TEST-3"
assert result[2]["fields"]["summary"] == "Third Issue"
assert result[2]["fields"]["status"]["name"] == "Done"
assert result[3]["key"] == "TEST-4"
assert result[3]["fields"]["summary"] == "Fourth Issue"
assert result[3]["fields"]["status"]["name"] == "Closed"
# None handling
assert result[3]["fields"].get("assignee") is None
assert result[4]["key"] == "TEST-5"
assert result[4]["fields"]["summary"] == "Fifth Issue"
assert result[4]["fields"]["status"]["name"] == "Open"
# Verify V3 client was called the correct number of times with correct parameters
assert mock_v3_client.search_issues.call_count == 3
# Check first call
first_call = mock_v3_client.search_issues.call_args_list[0]
assert first_call[1]["jql"] == "project = TEST"
assert first_call[1]["start_at"] == 0
assert first_call[1]["max_results"] == 10
# Check second call
second_call = mock_v3_client.search_issues.call_args_list[1]
assert second_call[1]["jql"] == "project = TEST"
assert second_call[1]["start_at"] == 2 # After first 2 issues
assert second_call[1]["max_results"] == 8 # Remaining needed: 10 - 2 = 8, min(8, 100) = 8
# Check third call
third_call = mock_v3_client.search_issues.call_args_list[2]
assert third_call[1]["jql"] == "project = TEST"
assert third_call[1]["start_at"] == 4 # After first 4 issues
assert third_call[1]["max_results"] == 6 # Remaining needed: 10 - 4 = 6, min(6, 100) = 6
@pytest.mark.asyncio
async def test_server_search_issues_pagination_with_limit(self):
"""Test JiraServer search_issues method respects max_results when paginating"""
# Mock V3 API responses for multiple pages, but we'll limit results
page1_response = {
"issues": [
{"key": "TEST-1", "fields": {"summary": "First Issue"}},
{"key": "TEST-2", "fields": {"summary": "Second Issue"}},
{"key": "TEST-3", "fields": {"summary": "Third Issue"}}
],
"startAt": 0,
"maxResults": 3,
"total": 10,
"isLast": False
}
page2_response = {
"issues": [
{"key": "TEST-4", "fields": {"summary": "Fourth Issue"}},
{"key": "TEST-5", "fields": {"summary": "Fifth Issue"}}
],
"startAt": 3,
"maxResults": 2, # Only 2 more to reach our limit of 5
"total": 10,
"isLast": False
}
# Mock V3 API client
mock_v3_client = AsyncMock()
mock_v3_client.search_issues.side_effect = [page1_response, page2_response]
# Create JiraServer instance and mock the V3 client
server = JiraServer()
server.server_url = "https://test.atlassian.net"
server.username = "testuser"
server.token = "testtoken"
with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
# Request only 5 results max
result = await server.search_jira_issues("project = TEST", max_results=5)
# Verify exactly 5 issues were returned (respecting max_results)
assert isinstance(result, list)
assert len(result) == 5
assert result[0]["key"] == "TEST-1"
assert result[1]["key"] == "TEST-2"
assert result[2]["key"] == "TEST-3"
assert result[3]["key"] == "TEST-4"
assert result[4]["key"] == "TEST-5"
# Verify pagination stopped at the right point
assert mock_v3_client.search_issues.call_count == 2
```
--------------------------------------------------------------------------------
/src/mcp_server_jira/jira_v3_api.py:
--------------------------------------------------------------------------------
```python
"""
Jira v3 REST API client module
This module provides direct HTTP client functionality for Jira's v3 REST API,
offering enhanced functionality and security for operations that require the latest API features.
"""
import json
import logging
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger("JiraMCPLogger") # Get the same logger instance
class JiraV3APIClient:
"""Client for making direct requests to Jira's v3 REST API"""
def __init__(
self,
server_url: str,
username: Optional[str] = None,
password: Optional[str] = None,
token: Optional[str] = None,
):
"""Initialize the v3 API client
Args:
server_url: Jira server URL
username: Username for authentication
password: Password for basic auth
token: API token for auth
"""
self.server_url = server_url.rstrip("/")
self.username = username
self.auth_token = token or password
if not self.username or not self.auth_token:
raise ValueError(
"Jira username and an API token (or password) are required for v3 API."
)
self.client = httpx.AsyncClient(
auth=(self.username, self.auth_token),
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=30.0,
follow_redirects=True,
)
async def _make_v3_api_request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Sends an authenticated async HTTP request to a Jira v3 REST API endpoint.
"""
url = f"{self.server_url}/rest/api/3{endpoint}"
logger.debug(f"Attempting to make request: {method} {url}")
logger.debug(f"Request params: {params}")
logger.debug(f"Request JSON data: {data}")
try:
logger.info(f"AWAITING httpx.client.request for {method} {url}")
response = await self.client.request(
method=method.upper(),
url=url,
json=data,
params=params,
)
logger.info(
f"COMPLETED httpx.client.request for {url}. Status: {response.status_code}"
)
logger.debug(f"Raw response text (first 500 chars): {str(response.text)[:500]}")
response.raise_for_status()
if response.status_code == 204:
return {}
return response.json()
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP Status Error for {e.request.url!r}: {e.response.status_code}",
exc_info=True,
)
error_details = f"Jira API returned an error: {e.response.status_code} {e.response.reason_phrase}."
raise ValueError(error_details)
except httpx.RequestError as e:
logger.error(f"Request Error for {e.request.url!r}", exc_info=True)
raise ValueError(f"A network error occurred while connecting to Jira: {e}")
except Exception as e:
logger.critical(
"An unexpected error occurred in _make_v3_api_request", exc_info=True
)
raise
async def create_project(
self,
key: str,
assignee: str,
name: Optional[str] = None,
ptype: str = None,
template_name: Optional[str] = None,
avatarId: Optional[int] = None,
issueSecurityScheme: Optional[int] = None,
permissionScheme: Optional[int] = None,
projectCategory: Optional[int] = None,
notificationScheme: Optional[int] = None,
categoryId: Optional[int] = None,
url: str = None,
) -> Dict[str, Any]:
"""
Creates a new Jira project using the v3 REST API.
Requires a project key and the Atlassian accountId of the project lead (`assignee`). The v3 API mandates that `leadAccountId` is always provided, regardless of default project lead settings or UI behavior. Additional project attributes such as name, type, template, avatar, schemes, category, and documentation URL can be specified.
Args:
key: The unique project key (required).
name: The project name. Defaults to the key if not provided.
assignee: Atlassian accountId of the project lead (required by v3 API).
ptype: Project type key (e.g., 'software', 'business', 'service_desk').
template_name: Project template key for template-based creation.
avatarId: ID of the avatar to assign to the project.
issueSecurityScheme: ID of the issue security scheme.
permissionScheme: ID of the permission scheme.
projectCategory: ID of the project category.
notificationScheme: ID of the notification scheme.
categoryId: Alternative to projectCategory; preferred for v3 API.
url: URL for project information or documentation.
Returns:
A dictionary containing details of the created project as returned by Jira.
Raises:
ValueError: If required parameters are missing or project creation fails.
"""
if not key:
raise ValueError("Project key is required")
if not assignee:
raise ValueError(
"Parameter 'assignee' (leadAccountId) is required by the Jira v3 API"
)
payload = {
"key": key,
"name": name or key,
"leadAccountId": assignee,
"assigneeType": "PROJECT_LEAD",
"projectTypeKey": ptype,
"projectTemplateKey": template_name,
"avatarId": avatarId,
"issueSecurityScheme": issueSecurityScheme,
"permissionScheme": permissionScheme,
"notificationScheme": notificationScheme,
"categoryId": categoryId or projectCategory,
"url": url,
}
payload = {k: v for k, v in payload.items() if v is not None}
print(f"Creating project with v3 API payload: {json.dumps(payload, indent=2)}")
response_data = await self._make_v3_api_request(
"POST", "/project", data=payload
)
print(f"Project creation response: {json.dumps(response_data, indent=2)}")
return response_data
async def get_projects(
self,
start_at: int = 0,
max_results: int = 50,
order_by: Optional[str] = None,
ids: Optional[list] = None,
keys: Optional[list] = None,
query: Optional[str] = None,
type_key: Optional[str] = None,
category_id: Optional[int] = None,
action: Optional[str] = None,
expand: Optional[str] = None,
) -> Dict[str, Any]:
"""
Get projects paginated using the v3 REST API.
Returns a paginated list of projects visible to the user using the
/rest/api/3/project/search endpoint.
Args:
start_at: The index of the first item to return (default: 0)
max_results: The maximum number of items to return per page (default: 50)
order_by: Order the results by a field:
- category: Order by project category
- issueCount: Order by total number of issues
- key: Order by project key
- lastIssueUpdatedDate: Order by last issue update date
- name: Order by project name
- owner: Order by project lead
- archivedDate: Order by archived date
- deletedDate: Order by deleted date
ids: List of project IDs to return
keys: List of project keys to return
query: Filter projects by query string
type_key: Filter projects by type key
category_id: Filter projects by category ID
action: Filter by action permission (view, browse, edit)
expand: Expand additional project fields in response
Returns:
Dictionary containing the paginated response with projects and pagination info
Raises:
ValueError: If the API request fails
"""
params = {
"startAt": start_at,
"maxResults": max_results,
"orderBy": order_by,
"id": ids,
"keys": keys,
"query": query,
"typeKey": type_key,
"categoryId": category_id,
"action": action,
"expand": expand,
}
params = {k: v for k, v in params.items() if v is not None}
endpoint = "/project/search"
print(
f"Fetching projects with v3 API endpoint: {endpoint} with params: {params}"
)
response_data = await self._make_v3_api_request("GET", endpoint, params=params)
print(f"Projects API response: {json.dumps(response_data, indent=2)}")
return response_data
async def get_transitions(
self,
issue_id_or_key: str,
expand: Optional[str] = None,
transition_id: Optional[str] = None,
skip_remote_only_condition: Optional[bool] = None,
include_unavailable_transitions: Optional[bool] = None,
sort_by_ops_bar_and_status: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Get available transitions for an issue using the v3 REST API.
Returns either all transitions or a transition that can be performed by the user
on an issue, based on the issue's status.
Args:
issue_id_or_key: Issue ID or key (required)
expand: Expand additional transition fields in response
transition_id: Get only the transition matching this ID
skip_remote_only_condition: Skip remote-only conditions check
include_unavailable_transitions: Include transitions that can't be performed
sort_by_ops_bar_and_status: Sort transitions by operations bar and status
Returns:
Dictionary containing the transitions response with transition details
Raises:
ValueError: If the API request fails
"""
if not issue_id_or_key:
raise ValueError("issue_id_or_key is required")
params = {
"expand": expand,
"transitionId": transition_id,
"skipRemoteOnlyCondition": skip_remote_only_condition,
"includeUnavailableTransitions": include_unavailable_transitions,
"sortByOpsBarAndStatus": sort_by_ops_bar_and_status,
}
params = {k: v for k, v in params.items() if v is not None}
endpoint = f"/issue/{issue_id_or_key}/transitions"
logger.debug(
f"Fetching transitions with v3 API endpoint: {endpoint} with params: {params}"
)
response_data = await self._make_v3_api_request("GET", endpoint, params=params)
logger.debug(f"Transitions API response: {json.dumps(response_data, indent=2)}")
return response_data
async def transition_issue(
self,
issue_id_or_key: str,
transition_id: str,
fields: Optional[Dict[str, Any]] = None,
comment: Optional[str] = None,
history_metadata: Optional[Dict[str, Any]] = None,
properties: Optional[list] = None,
) -> Dict[str, Any]:
"""
Transition an issue using the v3 REST API.
Performs an issue transition and, if the transition has a screen,
updates the fields from the transition screen.
Args:
issue_id_or_key: Issue ID or key (required)
transition_id: ID of the transition to perform (required)
fields: Dict containing field names and values to update during transition
comment: Simple string comment to add during transition
history_metadata: Optional history metadata for the transition
properties: Optional list of properties to set
Returns:
Empty dictionary on success (204 No Content response)
Raises:
ValueError: If required parameters are missing or transition fails
"""
if not issue_id_or_key:
raise ValueError("issue_id_or_key is required")
if not transition_id:
raise ValueError("transition_id is required")
# Build the request payload
payload = {"transition": {"id": transition_id}}
# Add fields if provided
if fields:
payload["fields"] = fields
# Add comment if provided - convert simple string to ADF format
if comment:
payload["update"] = {
"comment": [
{
"add": {
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": comment}],
}
],
}
}
}
]
}
# Add optional metadata
if history_metadata:
payload["historyMetadata"] = history_metadata
if properties:
payload["properties"] = properties
endpoint = f"/issue/{issue_id_or_key}/transitions"
logger.debug(f"Transitioning issue with v3 API endpoint: {endpoint}")
logger.debug(f"Transition payload: {json.dumps(payload, indent=2)}")
response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
logger.debug(f"Transition response: {response_data}")
return response_data
async def get_issue_types(self) -> Dict[str, Any]:
"""
Get all issue types for user using the v3 REST API.
Returns all issue types. This operation can be accessed anonymously.
Permissions required: Issue types are only returned as follows:
- if the user has the Administer Jira global permission, all issue types are returned.
- if the user has the Browse projects project permission for one or more projects,
the issue types associated with the projects the user has permission to browse are returned.
- if the user is anonymous then they will be able to access projects with the Browse projects for anonymous users
- if the user authentication is incorrect they will fall back to anonymous
Returns:
List of issue type dictionaries with fields like:
- avatarId: Avatar ID for the issue type
- description: Description of the issue type
- hierarchyLevel: Hierarchy level
- iconUrl: URL of the issue type icon
- id: Issue type ID
- name: Issue type name
- self: REST API URL for the issue type
- subtask: Whether this is a subtask type
Raises:
ValueError: If the API request fails
"""
endpoint = "/issuetype"
logger.debug(f"Fetching issue types with v3 API endpoint: {endpoint}")
response_data = await self._make_v3_api_request("GET", endpoint)
logger.debug(f"Issue types API response: {json.dumps(response_data, indent=2)}")
return response_data
async def add_comment(
self,
issue_id_or_key: str,
comment: str,
visibility: Optional[Dict[str, str]] = None,
properties: Optional[list] = None,
) -> Dict[str, Any]:
"""
Add a comment to an issue using the v3 REST API.
Args:
issue_id_or_key: Issue ID or key (required)
comment: Comment text to add (required)
visibility: Optional visibility settings (e.g., {"type": "role", "value": "Administrators"})
properties: Optional list of properties to set
Returns:
Dict containing comment details:
- id: Comment ID
- body: Comment body in ADF format
- author: Author information
- created: Creation timestamp
- updated: Last update timestamp
- etc.
Raises:
ValueError: If required parameters are missing or comment creation fails
"""
if not issue_id_or_key:
raise ValueError("issue_id_or_key is required")
if not comment:
raise ValueError("comment is required")
# Build the request payload with ADF format
payload = {
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": comment}],
}
],
}
}
# Add optional visibility
if visibility:
payload["visibility"] = visibility
# Add optional properties
if properties:
payload["properties"] = properties
endpoint = f"/issue/{issue_id_or_key}/comment"
logger.debug(f"Adding comment to issue {issue_id_or_key} with v3 API endpoint: {endpoint}")
response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
logger.debug(f"Add comment API response: {json.dumps(response_data, indent=2)}")
return response_data
async def create_issue(
self,
fields: Dict[str, Any],
update: Optional[Dict[str, Any]] = None,
history_metadata: Optional[Dict[str, Any]] = None,
properties: Optional[list] = None,
transition: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Create an issue using the v3 REST API.
Creates an issue or, where the option to create subtasks is enabled in Jira, a subtask.
A transition may be applied, to move the issue or subtask to a workflow step other than
the default start step, and issue properties set.
Args:
fields: Dict containing field names and values (required).
Must include project, summary, description, and issuetype.
update: Dict containing update operations for fields
history_metadata: Optional history metadata for the issue creation
properties: Optional list of properties to set
transition: Optional transition to apply after creation
Returns:
Dictionary containing the created issue details:
- id: Issue ID
- key: Issue key
- self: URL to the created issue
- transition: Transition result if applied
Raises:
ValueError: If required parameters are missing or creation fails
"""
if not fields:
raise ValueError("fields is required")
# Build the request payload
payload = {"fields": fields}
# Add optional parameters
if update:
payload["update"] = update
if history_metadata:
payload["historyMetadata"] = history_metadata
if properties:
payload["properties"] = properties
if transition:
payload["transition"] = transition
endpoint = "/issue"
logger.debug(f"Creating issue with v3 API endpoint: {endpoint}")
logger.debug(f"Create issue payload: {json.dumps(payload, indent=2)}")
response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
logger.debug(f"Create issue response: {response_data}")
return response_data
async def search_issues(
self,
jql: str,
start_at: int = 0,
max_results: int = 50,
fields: Optional[str] = None,
expand: Optional[str] = None,
properties: Optional[list] = None,
fields_by_keys: Optional[bool] = None,
fail_fast: Optional[bool] = None,
reconcile_issues: Optional[list] = None,
) -> Dict[str, Any]:
"""
Search for issues using JQL enhanced search (GET) via v3 REST API.
Searches for issues using JQL. Recent updates might not be immediately visible
in the returned search results. If you need read-after-write consistency,
you can utilize the reconcileIssues parameter to ensure stronger consistency assurances.
This operation can be accessed anonymously.
Args:
jql: JQL query string
start_at: Index of the first issue to return (default: 0)
max_results: Maximum number of results to return (default: 50)
fields: Comma-separated list of fields to include in response
expand: Use expand to include additional information about issues
properties: List of issue properties to include in response
fields_by_keys: Reference fields by their key (rather than ID)
fail_fast: Fail fast when JQL query validation fails
reconcile_issues: List of issue IDs to reconcile for read-after-write consistency
Returns:
Dictionary containing search results with:
- issues: List of issue dictionaries
- isLast: Boolean indicating if this is the last page
- startAt: Starting index of results
- maxResults: Maximum results per page
- total: Total number of issues matching the query
Raises:
ValueError: If the API request fails or JQL is invalid
"""
if not jql:
raise ValueError("jql parameter is required")
# Build query parameters
params = {
"jql": jql,
"startAt": start_at,
"maxResults": max_results,
}
# Add optional parameters if provided
params["fields"] = fields if fields is not None else "*all"
if expand:
params["expand"] = expand
if properties:
params["properties"] = properties
if fields_by_keys is not None:
params["fieldsByKeys"] = fields_by_keys
if fail_fast is not None:
params["failFast"] = fail_fast
if reconcile_issues:
params["reconcileIssues"] = reconcile_issues
# Remove None values
params = {k: v for k, v in params.items() if v is not None}
endpoint = "/search/jql"
logger.debug(f"Searching issues with v3 API endpoint: {endpoint}")
logger.debug(f"Search params: {params}")
response_data = await self._make_v3_api_request("GET", endpoint, params=params)
logger.debug(f"Search issues API response: {json.dumps(response_data, indent=2)}")
return response_data
async def bulk_create_issues(
self,
issue_updates: list
) -> Dict[str, Any]:
"""
Bulk create issues using the v3 REST API.
Creates up to 50 issues and, where the option to create subtasks is enabled in Jira,
subtasks. Transitions may be applied, to move the issues or subtasks to a workflow
step other than the default start step, and issue properties set.
Args:
issue_updates: List of issue creation specifications. Each item should contain
'fields' dict with issue fields, and optionally 'update' dict
for additional operations during creation.
Returns:
Dict containing:
- issues: List of successfully created issues with their details
- errors: List of errors for failed issue creations
Raises:
ValueError: If required parameters are missing or bulk creation fails
"""
if not issue_updates:
raise ValueError("issue_updates list cannot be empty")
if len(issue_updates) > 50:
raise ValueError("Cannot create more than 50 issues in a single bulk operation")
# Build the request payload for v3 API
payload = {"issueUpdates": issue_updates}
endpoint = "/issue/bulk"
logger.debug(f"Bulk creating issues with v3 API endpoint: {endpoint}")
logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
logger.debug(f"Bulk create response: {json.dumps(response_data, indent=2)}")
return response_data
```