diff --git a/examples/python/tools/jira_agent_example.py b/examples/python/tools/jira_agent_example.py new file mode 100644 index 000000000..98467b09c --- /dev/null +++ b/examples/python/tools/jira_agent_example.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +JIRA Agent Watch Example + +This example demonstrates how to use the JIRA watch tools with PraisonAI agents. +The agent can monitor JIRA issues and projects for changes, search for issues, +and get detailed issue information. + +Setup: +1. Install JIRA library: pip install jira +2. Set environment variables: + - JIRA_EMAIL=your_email@example.com (for cloud JIRA) + - JIRA_API_TOKEN=your_api_token + - Or use JIRA_USERNAME + JIRA_API_TOKEN for server JIRA + +Usage: + python jira_agent_example.py +""" + +import os +from praisonaiagents import Agent +from praisonaiagents.tools import jira_tools + + +def main(): + """Demonstrate JIRA agent watch capabilities.""" + + # Check if JIRA credentials are available + jira_email = os.getenv('JIRA_EMAIL') + jira_token = os.getenv('JIRA_API_TOKEN') + jira_username = os.getenv('JIRA_USERNAME') + + if not jira_token: + print("āŒ JIRA_API_TOKEN environment variable not set") + print("Please set your JIRA API token:") + print("export JIRA_API_TOKEN='your_token_here'") + return + + if not (jira_email or jira_username): + print("āŒ JIRA credentials incomplete") + print("Please set either:") + print("- JIRA_EMAIL (for cloud JIRA)") + print("- JIRA_USERNAME (for server JIRA)") + return + + # Create agent with JIRA tools + agent = Agent( + name="JIRA Monitor Agent", + instructions=""" + You are a JIRA monitoring agent. You can: + + 1. Watch specific JIRA issues for changes + 2. Monitor JIRA projects for new issues and updates + 3. Search for issues using JQL queries + 4. Get detailed information about specific issues + + When using JIRA tools, always provide the full JIRA URL + (e.g., https://yourcompany.atlassian.net). + + Be helpful and provide clear summaries of JIRA activity. + """, + tools=jira_tools(), # Add all JIRA tools + llm="gpt-4o-mini" + ) + + print("šŸŽÆ JIRA Agent Watch Example") + print("=" * 50) + print() + print("Available JIRA tools:") + print("- jira_watch_issue: Monitor a specific issue for changes") + print("- jira_watch_project: Monitor a project for new/updated issues") + print("- jira_get_issue_info: Get detailed info about an issue") + print("- jira_search_issues: Search issues using JQL") + print() + + # Example interactions + example_prompts = [ + "Get information about JIRA issue DEMO-1 from https://praisonai.atlassian.net", + "Search for open issues in project DEMO from https://praisonai.atlassian.net using JQL: 'project = DEMO AND status = Open'", + "Watch JIRA issue DEMO-1 from https://praisonai.atlassian.net for 2 minutes (check every 60 seconds, max 2 checks)", + ] + + print("šŸ“ Example prompts you can try:") + for i, prompt in enumerate(example_prompts, 1): + print(f"{i}. {prompt}") + print() + + # Interactive mode + print("šŸ’¬ Interactive JIRA Agent (type 'quit' to exit)") + print("-" * 50) + + while True: + try: + user_input = input("\nšŸ§‘ You: ").strip() + + if user_input.lower() in ['quit', 'exit', 'q']: + print("šŸ‘‹ Goodbye!") + break + + if not user_input: + continue + + print("šŸ¤– Agent: ", end="", flush=True) + response = agent.start(user_input) + print(response) + + except KeyboardInterrupt: + print("\nšŸ‘‹ Goodbye!") + break + except Exception as e: + print(f"āŒ Error: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/praisonai-agents/praisonaiagents/tools/__init__.py b/src/praisonai-agents/praisonaiagents/tools/__init__.py index 14c67f640..3cc34a7e4 100644 --- a/src/praisonai-agents/praisonaiagents/tools/__init__.py +++ b/src/praisonai-agents/praisonaiagents/tools/__init__.py @@ -171,6 +171,13 @@ 'github_create_pull_request': ('.github_tools', None), 'github_tools': ('.github_tools', None), + # JIRA Tools (watch and monitor JIRA issues/projects) + 'jira_watch_issue': ('.jira_tools', None), + 'jira_watch_project': ('.jira_tools', None), + 'jira_get_issue_info': ('.jira_tools', None), + 'jira_search_issues': ('.jira_tools', None), + 'jira_tools': ('.jira_tools', None), + # Schedule Tools (agent-centric scheduling) 'schedule_add': ('.schedule_tools', None), 'schedule_list': ('.schedule_tools', None), @@ -338,6 +345,7 @@ def __getattr__(name: str) -> Any: 'web_crawl', 'crawl_web', 'get_available_crawl_providers', 'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools', 'github_create_branch', 'github_commit_and_push', 'github_create_pull_request', + 'jira_watch_issue', 'jira_watch_project', 'jira_get_issue_info', 'jira_search_issues', 'schedule_add', 'schedule_list', 'schedule_remove', 'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools', 'store_memory', 'search_memory', @@ -348,7 +356,7 @@ def __getattr__(name: str) -> Any: 'clarify' ]: return getattr(module, name) - if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'github_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']: + if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'github_tools', 'jira_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']: return module # Returns the callable module return getattr(module, name) else: diff --git a/src/praisonai-agents/praisonaiagents/tools/jira_tools.py b/src/praisonai-agents/praisonaiagents/tools/jira_tools.py new file mode 100644 index 000000000..3120e974b --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/tools/jira_tools.py @@ -0,0 +1,374 @@ +"""JIRA Tools for PraisonAI Agents - Monitor and watch JIRA issues and projects""" +import os +import time +import logging +import re +from typing import Dict, List, Optional, Any +from .decorator import tool + +logger = logging.getLogger(__name__) + +def _get_jira_connection( + url: str, + username: Optional[str] = None, + token: Optional[str] = None, + email: Optional[str] = None +): + """Create JIRA connection with proper authentication. + + Args: + url: JIRA server URL + username: Username or use JIRA_USERNAME env var + token: API token or use JIRA_API_TOKEN env var + email: Email for cloud JIRA or use JIRA_EMAIL env var + """ + try: + from jira import JIRA + except ImportError: + raise ImportError( + "JIRA library not installed. Install with: pip install jira" + ) + + # Get credentials from environment if not provided + username = username or os.getenv('JIRA_USERNAME') + token = token or os.getenv('JIRA_API_TOKEN') + email = email or os.getenv('JIRA_EMAIL') + + if not url: + raise ValueError("JIRA URL is required") + + # For cloud JIRA, use email + token + if email and token: + auth = (email, token) + # For server JIRA, use username + token/password + elif username and token: + auth = (username, token) + else: + raise ValueError( + "JIRA authentication required. Provide either:\n" + "- email + token (for cloud JIRA)\n" + "- username + token (for server JIRA)\n" + "Or set environment variables: JIRA_EMAIL, JIRA_USERNAME, JIRA_API_TOKEN" + ) + + return JIRA(server=url, basic_auth=auth) + +@tool +def jira_watch_issue( + issue_key: str, + url: str, + since_timestamp: Optional[str] = None, + username: Optional[str] = None, + token: Optional[str] = None, + email: Optional[str] = None +) -> str: + """Check a specific JIRA issue for changes since a timestamp. + + Args: + issue_key: JIRA issue key (e.g., "PROJ-123") + url: JIRA server URL + since_timestamp: Check for changes since this ISO timestamp (optional) + username: Username or use JIRA_USERNAME env var + token: API token or use JIRA_API_TOKEN env var + email: Email for cloud JIRA or use JIRA_EMAIL env var + """ + try: + jira = _get_jira_connection(url, username, token, email) + + # Get current issue state + issue = jira.issue(issue_key, expand='changelog') + current_updated = issue.fields.updated + current_status = issue.fields.status.name + + logger.info(f"Checking JIRA issue {issue_key} for changes") + logger.info(f"Current status: {current_status}") + logger.info(f"Last updated: {current_updated}") + + changes_detected = [] + + # If no timestamp provided, just return current state + if not since_timestamp: + result = f"JIRA issue {issue_key} current state:\n" + result += f"Status: {current_status}\n" + result += f"Summary: {issue.fields.summary}\n" + result += f"Assignee: {issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned'}\n" + result += f"Priority: {issue.fields.priority.name if issue.fields.priority else 'None'}\n" + result += f"Updated: {current_updated}\n" + return result + + # Check if updated since timestamp + if current_updated > since_timestamp: + change_info = { + 'timestamp': current_updated, + 'status': current_status, + 'summary': issue.fields.summary, + 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned', + 'priority': issue.fields.priority.name if issue.fields.priority else 'None' + } + + # Get recent changelog entries + recent_changes = [] + if issue.changelog and issue.changelog.histories: + for history in issue.changelog.histories[-3:]: # Last 3 changes + if history.created > since_timestamp: + for item in history.items: + recent_changes.append({ + 'field': item.field, + 'from': item.fromString, + 'to': item.toString, + 'author': history.author.displayName, + 'created': history.created + }) + + change_info['recent_changes'] = recent_changes + changes_detected.append(change_info) + + logger.info(f"Change detected in {issue_key} at {current_updated}") + + # Check for recent comments + comments = jira.comments(issue_key) + comment_changes = [] + if comments: + for comment in comments: + if comment.created > since_timestamp: + comment_changes.append({ + 'author': comment.author.displayName, + 'body': comment.body[:500], # First 500 chars + 'created': comment.created + }) + + # Add comment changes as separate entries if they exist + if comment_changes: + change_info['recent_comments'] = comment_changes + + if changes_detected: + result = f"JIRA issue {issue_key} - changes detected since {since_timestamp}:\n" + for i, change in enumerate(changes_detected, 1): + result += f"\n--- Change {i} at {change['timestamp']} ---\n" + result += f"Status: {change['status']}\n" + result += f"Assignee: {change['assignee']}\n" + result += f"Priority: {change['priority']}\n" + + if change.get('recent_changes'): + result += "Recent field changes:\n" + for rc in change['recent_changes']: + result += f" - {rc['field']}: '{rc['from']}' → '{rc['to']}' by {rc['author']}\n" + + if change.get('recent_comments'): + result += "Recent comments:\n" + for comment in change['recent_comments']: + result += f" - {comment['author']} ({comment['created']}): {comment['body'][:200]}...\n" + + return result + else: + return f"No changes detected in JIRA issue {issue_key} since {since_timestamp}" + + except Exception as e: + logger.error(f"Failed to watch JIRA issue: {e}") + return f"Error watching JIRA issue {issue_key}: {e}" + +def _validate_project_key(project_key: str) -> bool: + """Validate JIRA project key to prevent injection. + + Args: + project_key: Project key to validate + + Returns: + True if valid, raises ValueError if invalid + """ + # JIRA project keys must be uppercase letters, numbers, and underscores + # and start with a letter + if not re.match(r'^[A-Z][A-Z0-9_]*$', project_key): + raise ValueError(f"Invalid project key format: {project_key}. Must start with letter and contain only uppercase letters, numbers, and underscores.") + return True + +@tool +def jira_watch_project( + project_key: str, + url: str, + since_timestamp: Optional[str] = None, + username: Optional[str] = None, + token: Optional[str] = None, + email: Optional[str] = None +) -> str: + """Check a JIRA project for new issues and updates since a timestamp. + + Args: + project_key: JIRA project key (e.g., "PROJ") + url: JIRA server URL + since_timestamp: Check for changes since this ISO timestamp (optional) + username: Username or use JIRA_USERNAME env var + token: API token or use JIRA_API_TOKEN env var + email: Email for cloud JIRA or use JIRA_EMAIL env var + """ + try: + # Validate project key to prevent JQL injection + _validate_project_key(project_key) + + jira = _get_jira_connection(url, username, token, email) + + logger.info(f"Checking JIRA project {project_key} for changes") + + project_changes = { + 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'), + 'new_issues': [], + 'updated_issues': [] + } + + # If no timestamp provided, return recent activity + if not since_timestamp: + recent_jql = f'project = {project_key} ORDER BY updated DESC' + recent_issues = jira.search_issues(recent_jql, maxResults=20) + + result = f"JIRA project {project_key} recent activity ({len(recent_issues)} issues):\n" + for issue in recent_issues[:10]: # Show top 10 + result += f" {issue.key}: {issue.fields.summary[:60]}...\n" + result += f" Status: {issue.fields.status.name}, Updated: {issue.fields.updated}\n" + return result + + # Check for new issues since timestamp + new_jql = f'project = {project_key} AND created >= "{since_timestamp}" ORDER BY created DESC' + new_issues = jira.search_issues(new_jql, maxResults=50) + + for issue in new_issues: + project_changes['new_issues'].append({ + 'key': issue.key, + 'summary': issue.fields.summary, + 'status': issue.fields.status.name, + 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned', + 'creator': issue.fields.creator.displayName, + 'created': issue.fields.created + }) + + # Check for updated issues since timestamp + updated_jql = f'project = {project_key} AND updated >= "{since_timestamp}" AND created < "{since_timestamp}" ORDER BY updated DESC' + updated_issues = jira.search_issues(updated_jql, maxResults=50) + + for issue in updated_issues: + project_changes['updated_issues'].append({ + 'key': issue.key, + 'summary': issue.fields.summary, + 'status': issue.fields.status.name, + 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned', + 'updated': issue.fields.updated + }) + + if project_changes['new_issues'] or project_changes['updated_issues']: + result = f"JIRA project {project_key} - changes detected since {since_timestamp}:\n" + result += f"\n--- Activity at {project_changes['timestamp']} ---\n" + + if project_changes['new_issues']: + result += f"New issues ({len(project_changes['new_issues'])}):\n" + for issue in project_changes['new_issues']: + result += f" šŸ“ {issue['key']}: {issue['summary'][:80]}...\n" + result += f" Status: {issue['status']}, Assignee: {issue['assignee']}, Created: {issue['created']}\n" + + if project_changes['updated_issues']: + result += f"Updated issues ({len(project_changes['updated_issues'])}):\n" + for issue in project_changes['updated_issues']: + result += f" šŸ”„ {issue['key']}: {issue['summary'][:80]}...\n" + result += f" Status: {issue['status']}, Updated: {issue['updated']}\n" + + return result + else: + return f"No changes detected in JIRA project {project_key} since {since_timestamp}" + + except Exception as e: + logger.error(f"Failed to watch JIRA project: {e}") + return f"Error watching JIRA project {project_key}: {e}" + +@tool +def jira_get_issue_info( + issue_key: str, + url: str, + username: Optional[str] = None, + token: Optional[str] = None, + email: Optional[str] = None +) -> str: + """Get detailed information about a specific JIRA issue. + + Args: + issue_key: JIRA issue key (e.g., "PROJ-123") + url: JIRA server URL + username: Username or use JIRA_USERNAME env var + token: API token or use JIRA_API_TOKEN env var + email: Email for cloud JIRA or use JIRA_EMAIL env var + """ + try: + jira = _get_jira_connection(url, username, token, email) + issue = jira.issue(issue_key, expand='changelog,comments') + + result = f"JIRA Issue: {issue.key}\n" + result += f"Summary: {issue.fields.summary}\n" + result += f"Status: {issue.fields.status.name}\n" + result += f"Priority: {issue.fields.priority.name if issue.fields.priority else 'None'}\n" + result += f"Assignee: {issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned'}\n" + result += f"Reporter: {issue.fields.reporter.displayName}\n" + result += f"Created: {issue.fields.created}\n" + result += f"Updated: {issue.fields.updated}\n" + + if issue.fields.description: + result += f"Description: {issue.fields.description[:500]}...\n" + + # Recent comments + comments = jira.comments(issue_key) + if comments: + result += f"\nRecent Comments ({len(comments[-3:])}):\n" + for comment in comments[-3:]: + result += f" - {comment.author.displayName} ({comment.created}): {comment.body[:200]}...\n" + + return result + + except Exception as e: + logger.error(f"Failed to get JIRA issue info: {e}") + return f"Error getting JIRA issue {issue_key}: {e}" + +@tool +def jira_search_issues( + jql: str, + url: str, + max_results: int = 20, + username: Optional[str] = None, + token: Optional[str] = None, + email: Optional[str] = None +) -> str: + """Search JIRA issues using JQL (JIRA Query Language). + + Args: + jql: JQL query string (e.g., "project = PROJ AND status = Open") + url: JIRA server URL + max_results: Maximum number of results to return (default: 20) + username: Username or use JIRA_USERNAME env var + token: API token or use JIRA_API_TOKEN env var + email: Email for cloud JIRA or use JIRA_EMAIL env var + """ + try: + jira = _get_jira_connection(url, username, token, email) + issues = jira.search_issues(jql, maxResults=max_results) + + if not issues: + return f"No issues found for JQL: {jql}" + + result = f"Found {len(issues)} issues for JQL: {jql}\n\n" + + for issue in issues: + result += f"{issue.key}: {issue.fields.summary}\n" + result += f" Status: {issue.fields.status.name}\n" + result += f" Assignee: {issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned'}\n" + result += f" Updated: {issue.fields.updated}\n\n" + + return result + + except Exception as e: + logger.error(f"Failed to search JIRA issues: {e}") + return f"Error searching JIRA issues: {e}" + +# Module-level callable for tools collection +def jira_tools(): + """Return all JIRA tools as a collection.""" + return [ + jira_watch_issue, + jira_watch_project, + jira_get_issue_info, + jira_search_issues + ] \ No newline at end of file diff --git a/src/praisonai-agents/tests/unit/test_jira_tools.py b/src/praisonai-agents/tests/unit/test_jira_tools.py new file mode 100644 index 000000000..67cdb9945 --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_jira_tools.py @@ -0,0 +1,303 @@ +"""Unit tests for JIRA tools.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from praisonaiagents.tools.jira_tools import ( + jira_watch_issue, + jira_watch_project, + jira_get_issue_info, + jira_search_issues, + _get_jira_connection +) + + +class TestJIRAConnection: + """Test JIRA connection utility.""" + + @patch('jira.JIRA') + def test_connection_with_email_token(self, mock_jira): + """Test JIRA connection with email and token.""" + _get_jira_connection( + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + mock_jira.assert_called_once_with( + server="https://test.atlassian.net", + basic_auth=("test@example.com", "test_token") + ) + + @patch('jira.JIRA') + def test_connection_with_username_token(self, mock_jira): + """Test JIRA connection with username and token.""" + _get_jira_connection( + url="https://test.atlassian.net", + username="test_user", + token="test_token" + ) + mock_jira.assert_called_once_with( + server="https://test.atlassian.net", + basic_auth=("test_user", "test_token") + ) + + def test_connection_missing_auth(self): + """Test JIRA connection fails with missing auth.""" + with pytest.raises(ValueError, match="JIRA authentication required"): + _get_jira_connection(url="https://test.atlassian.net") + + def test_connection_missing_url(self): + """Test JIRA connection fails with missing URL.""" + with pytest.raises(ValueError, match="JIRA URL is required"): + _get_jira_connection(url="", email="test@example.com", token="token") + + +class TestJIRAGetIssueInfo: + """Test getting JIRA issue information.""" + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_get_issue_info_success(self, mock_connection): + """Test successfully getting issue info.""" + # Mock JIRA connection and issue + mock_jira = Mock() + mock_connection.return_value = mock_jira + + mock_issue = Mock() + mock_issue.key = "PROJ-123" + mock_issue.fields.summary = "Test issue" + mock_issue.fields.status.name = "Open" + mock_issue.fields.priority.name = "High" + mock_issue.fields.assignee.displayName = "John Doe" + mock_issue.fields.reporter.displayName = "Jane Smith" + mock_issue.fields.created = "2024-01-01T10:00:00" + mock_issue.fields.updated = "2024-01-02T15:30:00" + mock_issue.fields.description = "Test description" + + mock_jira.issue.return_value = mock_issue + mock_jira.comments.return_value = [] + + result = jira_get_issue_info( + issue_key="PROJ-123", + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + + assert "PROJ-123" in result + assert "Test issue" in result + assert "Open" in result + assert "High" in result + assert "John Doe" in result + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_get_issue_info_error(self, mock_connection): + """Test error handling in get issue info.""" + mock_connection.side_effect = Exception("Connection failed") + + result = jira_get_issue_info( + issue_key="PROJ-123", + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + + assert "Error getting JIRA issue PROJ-123" in result + + +class TestJIRASearchIssues: + """Test searching JIRA issues.""" + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_search_issues_success(self, mock_connection): + """Test successfully searching issues.""" + mock_jira = Mock() + mock_connection.return_value = mock_jira + + # Mock search results + mock_issue1 = Mock() + mock_issue1.key = "PROJ-123" + mock_issue1.fields.summary = "First issue" + mock_issue1.fields.status.name = "Open" + mock_issue1.fields.assignee.displayName = "John Doe" + mock_issue1.fields.updated = "2024-01-01T10:00:00" + + mock_issue2 = Mock() + mock_issue2.key = "PROJ-124" + mock_issue2.fields.summary = "Second issue" + mock_issue2.fields.status.name = "Closed" + mock_issue2.fields.assignee = None + mock_issue2.fields.updated = "2024-01-02T15:30:00" + + mock_jira.search_issues.return_value = [mock_issue1, mock_issue2] + + result = jira_search_issues( + jql="project = PROJ", + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + + assert "Found 2 issues" in result + assert "PROJ-123" in result + assert "PROJ-124" in result + assert "First issue" in result + assert "Second issue" in result + assert "Unassigned" in result + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_search_issues_no_results(self, mock_connection): + """Test search with no results.""" + mock_jira = Mock() + mock_connection.return_value = mock_jira + mock_jira.search_issues.return_value = [] + + result = jira_search_issues( + jql="project = NONEXISTENT", + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + + assert "No issues found" in result + + +class TestJIRAWatchIssue: + """Test JIRA issue watching functionality.""" + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_watch_issue_current_state(self, mock_connection): + """Test getting current state of an issue.""" + mock_jira = Mock() + mock_connection.return_value = mock_jira + + mock_issue = Mock() + mock_issue.fields.updated = "2024-01-01T10:00:00" + mock_issue.fields.status.name = "Open" + mock_issue.fields.summary = "Test issue" + mock_issue.fields.assignee.displayName = "John Doe" + mock_issue.fields.priority.name = "High" + + mock_jira.issue.return_value = mock_issue + + result = jira_watch_issue( + issue_key="PROJ-123", + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + + assert "current state" in result + assert "PROJ-123" in result + assert "Open" in result + assert "John Doe" in result + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_watch_issue_with_changes(self, mock_connection): + """Test watching an issue with changes detected since timestamp.""" + mock_jira = Mock() + mock_connection.return_value = mock_jira + + # Mock updated issue + mock_issue = Mock() + mock_issue.fields.updated = "2024-01-01T11:00:00" # After timestamp + mock_issue.fields.status.name = "In Progress" + mock_issue.fields.summary = "Test issue" + mock_issue.fields.assignee.displayName = "Jane Smith" + mock_issue.fields.priority.name = "High" + mock_issue.changelog = Mock() + mock_issue.changelog.histories = [] + + mock_jira.issue.return_value = mock_issue + mock_jira.comments.return_value = [] + + result = jira_watch_issue( + issue_key="PROJ-123", + url="https://test.atlassian.net", + since_timestamp="2024-01-01T10:00:00", + email="test@example.com", + token="test_token" + ) + + assert "changes detected" in result + assert "In Progress" in result + assert "Jane Smith" in result + + +class TestJIRAWatchProject: + """Test JIRA project watching functionality.""" + + @patch('praisonaiagents.tools.jira_tools._get_jira_connection') + def test_watch_project_recent_activity(self, mock_connection): + """Test getting recent activity from a project.""" + mock_jira = Mock() + mock_connection.return_value = mock_jira + + # Mock recent issues + mock_issue = Mock() + mock_issue.key = "PROJ-123" + mock_issue.fields.summary = "Test issue" + mock_issue.fields.status.name = "Open" + mock_issue.fields.updated = "2024-01-01T10:00:00" + + mock_jira.search_issues.return_value = [mock_issue] + + result = jira_watch_project( + project_key="PROJ", + url="https://test.atlassian.net", + email="test@example.com", + token="test_token" + ) + + assert "recent activity" in result + assert "PROJ" in result + assert "PROJ-123" in result + + +class TestJIRAValidation: + """Test JIRA input validation.""" + + def test_validate_project_key_valid(self): + """Test valid project key validation.""" + from praisonaiagents.tools.jira_tools import _validate_project_key + + # Valid keys + assert _validate_project_key("PROJ") + assert _validate_project_key("MY_PROJECT") + assert _validate_project_key("ABC123") + assert _validate_project_key("A") + + def test_validate_project_key_invalid(self): + """Test invalid project key validation.""" + from praisonaiagents.tools.jira_tools import _validate_project_key + + # Invalid keys + with pytest.raises(ValueError): + _validate_project_key("proj") # lowercase + with pytest.raises(ValueError): + _validate_project_key("123") # starts with number + with pytest.raises(ValueError): + _validate_project_key("PROJ-123") # contains dash + with pytest.raises(ValueError): + _validate_project_key("PROJ OR 1=1") # injection attempt + + +def test_jira_tools_import(): + """Test that JIRA tools can be imported from the tools package.""" + from praisonaiagents.tools import ( + jira_watch_issue, + jira_watch_project, + jira_get_issue_info, + jira_search_issues, + jira_tools + ) + + # Verify functions exist + assert callable(jira_watch_issue) + assert callable(jira_watch_project) + assert callable(jira_get_issue_info) + assert callable(jira_search_issues) + assert callable(jira_tools) + + # Verify jira_tools returns a list of tools + tools = jira_tools() + assert isinstance(tools, list) + assert len(tools) == 4 \ No newline at end of file