mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-10 03:31:29 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d915d18961 | ||
|
|
45c8c1d98f | ||
|
|
18b4b0c4d4 | ||
|
|
2d79dcbb01 | ||
|
|
b8d64b432c |
No files matched your search
@@ -5,10 +5,13 @@
|
||||
module.exports = {
|
||||
repoFullName: 'MervinPraison/PraisonAI-Tools',
|
||||
productPathPrefixes: ['praisonai_tools/', 'tests/'],
|
||||
// Workflows stay gated; pyproject.toml is normal for optional tool deps.
|
||||
sensitivePathPatterns: [
|
||||
/^\.github\/workflows\//,
|
||||
/^pyproject\.toml$/,
|
||||
],
|
||||
// Tool + tests + docs routinely exceed the UI/SDK 800-line default.
|
||||
maxAutoAdditions: 2000,
|
||||
maxAutoFiles: 30,
|
||||
requiredCheckPatterns: [/^ci$/i, /python/i, /test/i, /lint/i, /ruff/i],
|
||||
ciWorkflowFile: 'ci.yml',
|
||||
ciWorkflowName: 'CI',
|
||||
|
||||
@@ -212,6 +212,33 @@ assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictCommen
|
||||
[conflictTrigger, rebaseDone],
|
||||
headAfterRebase
|
||||
));
|
||||
assert(
|
||||
'CLEAN + head after conflict clears without completion comment',
|
||||
!mg.hasRecentConflictComment([conflictTrigger], headAfterRebase, 'CLEAN')
|
||||
);
|
||||
assert(
|
||||
'UNSTABLE + head after conflict clears without completion comment',
|
||||
!mg.hasRecentConflictComment([conflictTrigger], headAfterRebase, 'UNSTABLE')
|
||||
);
|
||||
assert(
|
||||
'DIRTY still blocks even with head after conflict',
|
||||
mg.hasRecentConflictComment([conflictTrigger], headAfterRebase, 'DIRTY')
|
||||
);
|
||||
assert(
|
||||
'owner rebase-complete comment clears conflict',
|
||||
!mg.hasRecentConflictComment(
|
||||
[
|
||||
conflictTrigger,
|
||||
{
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '**Rebase complete** — conflicts resolved.',
|
||||
created_at: conflictIso(-29 * 60 * 1000),
|
||||
},
|
||||
finalAfterRebase,
|
||||
],
|
||||
headAfterRebase
|
||||
)
|
||||
);
|
||||
|
||||
// Tests heuristic
|
||||
assert('product code without tests', mg.missingTestsReason([{ filename: productSample, additions: 3 }]) !== null);
|
||||
@@ -220,8 +247,11 @@ assert('product code with tests ok', mg.missingTestsReason([
|
||||
{ filename: 'tests/test_x.py', additions: 10 },
|
||||
]) === null);
|
||||
|
||||
// PR size
|
||||
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
|
||||
// PR size (threshold from gate-config; Tools allows larger tool+tests+docs PRs)
|
||||
const sizeLimit = config.maxAutoAdditions ?? 800;
|
||||
assert('under size limit ok', mg.prSizeReasons([{ additions: sizeLimit }]).length === 0);
|
||||
assert('large PR blocked', mg.prSizeReasons([{ additions: sizeLimit + 1 }]).length > 0);
|
||||
assert('typical tool PR under Tools limit', mg.prSizeReasons([{ additions: 1510 }]).length === 0);
|
||||
|
||||
assert('internal PR link accepted', mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: config.repoFullName } } },
|
||||
|
||||
@@ -14,8 +14,8 @@ const STALE_FINAL_MAX_PER_WINDOW = 2;
|
||||
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
|
||||
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
|
||||
const AGENT_PY_MAX_AUTO_LINES = 100;
|
||||
const PR_MAX_AUTO_ADDITIONS = 800;
|
||||
const PR_MAX_AUTO_FILES = 30;
|
||||
const PR_MAX_AUTO_ADDITIONS = config.maxAutoAdditions ?? 800;
|
||||
const PR_MAX_AUTO_FILES = config.maxAutoFiles ?? 30;
|
||||
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
|
||||
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
|
||||
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
|
||||
@@ -89,7 +89,11 @@ function isConflictRebaseTriggerComment(c) {
|
||||
|
||||
function isConflictRebaseCompletionComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
|
||||
const allowed =
|
||||
login.includes('praisonai-triage') ||
|
||||
login.includes('github-actions') ||
|
||||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
|
||||
if (!allowed) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
@@ -116,7 +120,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
function hasRecentConflictComment(comments, headPushedAt = null, mergeStateStatus = null) {
|
||||
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
|
||||
const hasRecentTrigger = comments.some((c) => {
|
||||
if (!isConflictRebaseTriggerComment(c)) return false;
|
||||
@@ -127,6 +131,24 @@ function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// GitHub mergeable (CLEAN/UNSTABLE) and HEAD pushed after the conflict trigger → resolved
|
||||
const status = String(mergeStateStatus || '').toUpperCase();
|
||||
if (headPushedAt && ALLOWED_MERGE_STATES.has(status)) {
|
||||
// conflictTriggers is guaranteed non-empty here (hasRecentTrigger is true above);
|
||||
// the null seed keeps reduce() safe if that invariant ever changes.
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
const latestConflict = conflictTriggers.reduce(
|
||||
(a, b) => (a && new Date(a.created_at) > new Date(b.created_at) ? a : b),
|
||||
null
|
||||
);
|
||||
if (
|
||||
latestConflict &&
|
||||
new Date(headPushedAt).getTime() > new Date(latestConflict.created_at).getTime()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -672,7 +694,7 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
|
||||
reasons.push('fork PR');
|
||||
}
|
||||
|
||||
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
|
||||
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt, status)) {
|
||||
reasons.push('recent merge-conflict @claude');
|
||||
}
|
||||
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
|
||||
|
||||
@@ -39,7 +39,9 @@ jobs:
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'Release v') &&
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'Bump ')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -660,6 +660,29 @@ agent.start("Check the weather in London and send a Slack message about it")
|
||||
|
||||
---
|
||||
|
||||
## Composio Integration
|
||||
|
||||
Load 250+ managed app integrations (GitHub, Slack, Gmail, Jira, etc.) as agent-callable tools:
|
||||
|
||||
```bash
|
||||
pip install praisonai-tools[composio]
|
||||
export COMPOSIO_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools import composio_tools, composio_list_apps
|
||||
|
||||
print(composio_list_apps()[:5]) # ["github", "slack", ...]
|
||||
|
||||
agent = Agent(name="dev", tools=composio_tools(apps=["github"]))
|
||||
agent.start("Star the praisonai/PraisonAI repository")
|
||||
```
|
||||
|
||||
For explicit action execution, use `ComposioTool` or `composio_execute()`.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! To add a new tool:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JIRA Agent watch example using praisonai-tools.
|
||||
|
||||
Setup:
|
||||
pip install praisonai-tools jira
|
||||
export JIRA_URL=https://yourcompany.atlassian.net
|
||||
export [email protected]
|
||||
export JIRA_API_TOKEN=your_api_token
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools import jira_tools
|
||||
|
||||
|
||||
def main():
|
||||
jira_url = os.getenv("JIRA_URL")
|
||||
jira_token = os.getenv("JIRA_API_TOKEN")
|
||||
jira_email = os.getenv("JIRA_EMAIL")
|
||||
jira_username = os.getenv("JIRA_USERNAME")
|
||||
|
||||
if not jira_url or not jira_token or not (jira_email or jira_username):
|
||||
print("Set JIRA_URL, JIRA_API_TOKEN, and JIRA_EMAIL (or JIRA_USERNAME)")
|
||||
return
|
||||
|
||||
agent = Agent(
|
||||
name="JIRA Monitor Agent",
|
||||
instructions=(
|
||||
"You monitor JIRA issues and projects. Use jira_watch_issue, "
|
||||
"jira_watch_project, jira_get_issue_info, and jira_search_issues."
|
||||
),
|
||||
tools=jira_tools(),
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
print("JIRA watch tools ready. Example prompts:")
|
||||
print(" Get info for PROJ-123")
|
||||
print(" Search open issues: project = PROJ AND status = Open")
|
||||
print(" Watch PROJ-123 for changes since 2024-01-01T00:00:00")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("\nYou: ").strip()
|
||||
if user_input.lower() in {"quit", "exit", "q"}:
|
||||
break
|
||||
if not user_input:
|
||||
continue
|
||||
print("Agent:", agent.start(user_input))
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -144,4 +144,10 @@ __all__ = [
|
||||
"AgentFolioTool",
|
||||
"check_behavioral_trust",
|
||||
"verify_task_delegation_safety",
|
||||
# Composio
|
||||
"ComposioTool",
|
||||
"ComposioTools",
|
||||
"composio_execute",
|
||||
"composio_tools",
|
||||
"composio_list_apps",
|
||||
]
|
||||
@@ -139,6 +139,11 @@ def __getattr__(name):
|
||||
"jira_get_transitions": "jira_tool",
|
||||
"jira_move_issue": "jira_tool",
|
||||
"jira_create_task": "jira_tool",
|
||||
"jira_watch_issue": "jira_watch_tool",
|
||||
"jira_watch_project": "jira_watch_tool",
|
||||
"jira_get_issue_info": "jira_watch_tool",
|
||||
"jira_search_issues": "jira_watch_tool",
|
||||
"jira_tools": "jira_watch_tool",
|
||||
# Trello
|
||||
"TrelloTool": "trello_tool",
|
||||
"list_trello_boards": "trello_tool",
|
||||
@@ -442,7 +447,10 @@ def __getattr__(name):
|
||||
"daytona_list_workspaces": "daytona_tool",
|
||||
# Composio
|
||||
"ComposioTool": "composio_tool",
|
||||
"ComposioTools": "composio_tool",
|
||||
"composio_execute": "composio_tool",
|
||||
"composio_tools": "composio_tool",
|
||||
"composio_list_apps": "composio_tool",
|
||||
# JSON
|
||||
"JSONTool": "json_tool",
|
||||
"read_json": "json_tool",
|
||||
@@ -681,6 +689,11 @@ __all__ = [
|
||||
"jira_get_transitions",
|
||||
"jira_move_issue",
|
||||
"jira_create_task",
|
||||
"jira_watch_issue",
|
||||
"jira_watch_project",
|
||||
"jira_get_issue_info",
|
||||
"jira_search_issues",
|
||||
"jira_tools",
|
||||
# Trello Tool
|
||||
"TrelloTool",
|
||||
"list_trello_boards",
|
||||
|
||||
@@ -1,72 +1,215 @@
|
||||
"""Composio Tool for PraisonAI Agents.
|
||||
"""Composio integration tools for PraisonAI Agents.
|
||||
|
||||
Execute actions via Composio integrations.
|
||||
Composio provides 250+ managed app integrations exposed as agent-callable tools.
|
||||
|
||||
Requires: pip install praisonai-tools[composio]
|
||||
Environment: COMPOSIO_API_KEY
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import ComposioTool
|
||||
|
||||
composio = ComposioTool()
|
||||
result = composio.execute("GITHUB_CREATE_ISSUE", {"repo": "owner/repo", "title": "Bug"})
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools import composio_tools
|
||||
|
||||
Environment Variables:
|
||||
COMPOSIO_API_KEY: Composio API key
|
||||
agent = Agent(name="dev", tools=composio_tools(apps=["github"]))
|
||||
agent.start("Star the praisonai/PraisonAI repository")
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import os
|
||||
from importlib import util
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_composio_available(api_key: Optional[str] = None) -> tuple[bool, Optional[str]]:
|
||||
"""Return (is_available, error_message).
|
||||
|
||||
``api_key`` takes precedence over the ``COMPOSIO_API_KEY`` environment
|
||||
variable so callers who pass a key explicitly are not forced to also set
|
||||
the env var.
|
||||
"""
|
||||
if util.find_spec("composio") is None:
|
||||
return False, "composio package is not installed. Install with: pip install praisonai-tools[composio]"
|
||||
|
||||
if not api_key and not os.environ.get("COMPOSIO_API_KEY"):
|
||||
return False, (
|
||||
"COMPOSIO_API_KEY environment variable is not set. "
|
||||
"Please set it to use Composio tools."
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class ComposioTools:
|
||||
"""Composio managed-integration tools for Agent(tools=[...])."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or os.environ.get("COMPOSIO_API_KEY")
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
try:
|
||||
from composio import Composio
|
||||
|
||||
self._client = Composio(api_key=self.api_key)
|
||||
except ImportError:
|
||||
from composio import ComposioToolSet
|
||||
|
||||
self._client = ComposioToolSet(api_key=self.api_key)
|
||||
return self._client
|
||||
|
||||
def get_tools(
|
||||
self,
|
||||
apps: Optional[List[str]] = None,
|
||||
actions: Optional[List[str]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Callable]:
|
||||
"""Fetch Composio tools as agent-callable Python functions."""
|
||||
is_available, error = _check_composio_available(self.api_key)
|
||||
if not is_available:
|
||||
logger.error(error)
|
||||
return []
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
if hasattr(client, "tools") and hasattr(client.tools, "get"):
|
||||
# Composio v3 SDK: `apps` -> `toolkits`, `user_id` required.
|
||||
# The API key is an auth credential, not an entity identifier,
|
||||
# so never use it as a user_id fallback.
|
||||
kwargs: Dict[str, Any] = {"user_id": user_id or "default"}
|
||||
if apps:
|
||||
kwargs["toolkits"] = apps
|
||||
if actions:
|
||||
kwargs["tools"] = actions
|
||||
if tags:
|
||||
kwargs["tags"] = tags
|
||||
tools = client.tools.get(**kwargs)
|
||||
else:
|
||||
# Legacy ComposioToolSet SDK.
|
||||
toolset_kwargs: Dict[str, Any] = {}
|
||||
if apps:
|
||||
toolset_kwargs["apps"] = apps
|
||||
if actions:
|
||||
toolset_kwargs["actions"] = actions
|
||||
if tags:
|
||||
toolset_kwargs["tags"] = tags
|
||||
tools = client.get_tools(**toolset_kwargs)
|
||||
|
||||
return list(tools) if tools else []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Composio get_tools error: {e}")
|
||||
return []
|
||||
|
||||
def list_apps(self) -> List[str]:
|
||||
"""List available Composio app slugs."""
|
||||
is_available, error = _check_composio_available(self.api_key)
|
||||
if not is_available:
|
||||
logger.error(error)
|
||||
return []
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
if hasattr(client, "apps") and hasattr(client.apps, "get"):
|
||||
apps = client.apps.get()
|
||||
elif hasattr(client, "get_apps"):
|
||||
apps = client.get_apps()
|
||||
else:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for app in apps or []:
|
||||
if isinstance(app, str):
|
||||
slug = app
|
||||
elif isinstance(app, dict):
|
||||
slug = app.get("key") or app.get("name") or app.get("slug")
|
||||
else:
|
||||
slug = (
|
||||
getattr(app, "key", None)
|
||||
or getattr(app, "name", None)
|
||||
or getattr(app, "slug", None)
|
||||
)
|
||||
if slug:
|
||||
result.append(slug)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Composio list_apps error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def composio_tools(
|
||||
apps: Optional[List[str]] = None,
|
||||
actions: Optional[List[str]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> List[Callable]:
|
||||
"""Load Composio tools as agent-callable Python functions."""
|
||||
return ComposioTools(api_key=api_key).get_tools(
|
||||
apps=apps, actions=actions, tags=tags, user_id=user_id
|
||||
)
|
||||
|
||||
|
||||
def composio_list_apps(api_key: Optional[str] = None) -> List[str]:
|
||||
"""List available Composio app slugs."""
|
||||
return ComposioTools(api_key=api_key).list_apps()
|
||||
|
||||
|
||||
class ComposioTool(BaseTool):
|
||||
"""Tool for Composio integrations."""
|
||||
|
||||
"""Single-tool wrapper for explicit Composio action execution."""
|
||||
|
||||
name = "composio"
|
||||
description = "Execute actions via Composio integrations."
|
||||
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or os.getenv("COMPOSIO_API_KEY")
|
||||
self._toolset = None
|
||||
super().__init__()
|
||||
|
||||
|
||||
@property
|
||||
def toolset(self):
|
||||
if self._toolset is None:
|
||||
try:
|
||||
from composio import ComposioToolSet
|
||||
except ImportError:
|
||||
raise ImportError("composio-core not installed. Install with: pip install composio-core")
|
||||
raise ImportError(
|
||||
"composio not installed. Install with: pip install praisonai-tools[composio]"
|
||||
)
|
||||
self._toolset = ComposioToolSet(api_key=self.api_key)
|
||||
return self._toolset
|
||||
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "execute",
|
||||
action_name: Optional[str] = None,
|
||||
params: Optional[Dict] = None,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
if action == "execute":
|
||||
return self.execute(action_name=action_name, params=params or kwargs)
|
||||
elif action == "list_actions":
|
||||
if action == "list_actions":
|
||||
return self.list_actions(app=kwargs.get("app"))
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
|
||||
def execute(self, action_name: str, params: Dict = None) -> Dict[str, Any]:
|
||||
"""Execute a Composio action."""
|
||||
if not action_name:
|
||||
return {"error": "action_name is required"}
|
||||
|
||||
|
||||
try:
|
||||
result = self.toolset.execute_action(action_name, params or {})
|
||||
return {"result": result}
|
||||
except Exception as e:
|
||||
logger.error(f"Composio execute error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def list_actions(self, app: str = None) -> List[Dict[str, Any]]:
|
||||
"""List available actions."""
|
||||
try:
|
||||
@@ -81,5 +224,5 @@ class ComposioTool(BaseTool):
|
||||
|
||||
|
||||
def composio_execute(action_name: str, params: Dict = None) -> Dict[str, Any]:
|
||||
"""Execute Composio action."""
|
||||
"""Execute a Composio action."""
|
||||
return ComposioTool().execute(action_name=action_name, params=params)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""JIRA watch tools for PraisonAI Agents — monitor issues and projects for changes."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_datetime(ts_str: str) -> datetime:
|
||||
"""Parse a timestamp string into a timezone-aware datetime for safe comparison."""
|
||||
dt = datetime.fromisoformat(str(ts_str).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _get_jira_connection(
|
||||
url: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
):
|
||||
"""Create a JIRA connection with cloud or server authentication."""
|
||||
try:
|
||||
from jira import JIRA
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"JIRA library not installed. Install with: pip install jira"
|
||||
) from exc
|
||||
|
||||
url = url or os.getenv("JIRA_URL")
|
||||
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 (parameter or JIRA_URL env var)")
|
||||
|
||||
if email and token:
|
||||
auth = (email, token)
|
||||
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 JIRA_EMAIL, JIRA_USERNAME, and JIRA_API_TOKEN"
|
||||
)
|
||||
|
||||
return JIRA(server=url, basic_auth=auth)
|
||||
|
||||
|
||||
def _validate_project_key(project_key: str) -> bool:
|
||||
"""Validate JIRA project key to prevent JQL injection."""
|
||||
if not re.match(r"^[A-Z][A-Z0-9_]*$", project_key):
|
||||
raise ValueError(
|
||||
f"Invalid project key format: {project_key}. "
|
||||
"Must start with a letter and contain only uppercase letters, numbers, and underscores."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_issue_key(issue_key: str) -> bool:
|
||||
"""Validate JIRA issue key to prevent path traversal or API abuse."""
|
||||
if not re.match(r"^[A-Za-z0-9_-]+$", issue_key):
|
||||
raise ValueError(
|
||||
f"Invalid issue key format: {issue_key}. "
|
||||
"Must contain only alphanumeric characters, underscores, and hyphens."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_timestamp(timestamp: str) -> str:
|
||||
"""Validate and normalize a timestamp to prevent JQL injection.
|
||||
|
||||
Returns a canonical 'YYYY-MM-DD HH:MM' string safe for JQL interpolation.
|
||||
"""
|
||||
try:
|
||||
dt = _parse_datetime(timestamp)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f"Invalid timestamp format: {timestamp}. "
|
||||
"Use ISO-8601, e.g. 2024-01-01T00:00:00Z"
|
||||
) from exc
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
@tool
|
||||
def jira_watch_issue(
|
||||
issue_key: str,
|
||||
url: Optional[str] = None,
|
||||
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."""
|
||||
try:
|
||||
_validate_issue_key(issue_key)
|
||||
since_dt = _parse_datetime(since_timestamp) if since_timestamp else None
|
||||
jira = _get_jira_connection(url, username, token, email)
|
||||
issue = jira.issue(issue_key, expand="changelog")
|
||||
current_updated = issue.fields.updated
|
||||
current_status = issue.fields.status.name
|
||||
|
||||
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"
|
||||
assignee = issue.fields.assignee.displayName if issue.fields.assignee else "Unassigned"
|
||||
result += f"Assignee: {assignee}\n"
|
||||
priority = issue.fields.priority.name if issue.fields.priority else "None"
|
||||
result += f"Priority: {priority}\n"
|
||||
result += f"Updated: {current_updated}\n"
|
||||
return result
|
||||
|
||||
changes_detected = []
|
||||
if _parse_datetime(current_updated) > since_dt:
|
||||
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",
|
||||
}
|
||||
|
||||
recent_changes = []
|
||||
if issue.changelog and issue.changelog.histories:
|
||||
for history in issue.changelog.histories:
|
||||
try:
|
||||
history_dt = _parse_datetime(history.created)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if history_dt > since_dt:
|
||||
for item in history.items:
|
||||
recent_changes.append({
|
||||
"field": item.field,
|
||||
"from": item.fromString,
|
||||
"to": item.toString,
|
||||
"author": history.author.displayName if history.author else "System",
|
||||
"created": history.created,
|
||||
})
|
||||
|
||||
change_info["recent_changes"] = recent_changes
|
||||
changes_detected.append(change_info)
|
||||
|
||||
comment_changes = []
|
||||
for comment in jira.comments(issue_key):
|
||||
try:
|
||||
comment_dt = _parse_datetime(comment.created)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if comment_dt > since_dt:
|
||||
comment_changes.append({
|
||||
"author": comment.author.displayName if comment.author else "Unknown",
|
||||
"body": comment.body[:500],
|
||||
"created": comment.created,
|
||||
})
|
||||
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"):
|
||||
field_changes = change["recent_changes"]
|
||||
result += f"Recent field changes ({len(field_changes)}):\n"
|
||||
for rc in field_changes[-10:]:
|
||||
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
|
||||
|
||||
return f"No changes detected in JIRA issue {issue_key} since {since_timestamp}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to watch JIRA issue: %s", e)
|
||||
return f"Error watching JIRA issue {issue_key}: {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def jira_watch_project(
|
||||
project_key: str,
|
||||
url: Optional[str] = None,
|
||||
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."""
|
||||
try:
|
||||
_validate_project_key(project_key)
|
||||
since_for_jql = _validate_timestamp(since_timestamp) if since_timestamp else None
|
||||
jira = _get_jira_connection(url, username, token, email)
|
||||
|
||||
project_changes = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"new_issues": [],
|
||||
"updated_issues": [],
|
||||
}
|
||||
|
||||
if not since_timestamp:
|
||||
recent_jql = f"project = {project_key} ORDER BY updated DESC"
|
||||
recent_issues = jira.search_issues(recent_jql, maxResults=10)
|
||||
result = f"JIRA project {project_key} recent activity ({len(recent_issues)} issues):\n"
|
||||
for issue in recent_issues:
|
||||
result += f" {issue.key}: {issue.fields.summary[:60]}...\n"
|
||||
result += f" Status: {issue.fields.status.name}, Updated: {issue.fields.updated}\n"
|
||||
return result
|
||||
|
||||
new_jql = f'project = {project_key} AND created >= "{since_for_jql}" ORDER BY created DESC'
|
||||
for issue in jira.search_issues(new_jql, maxResults=50):
|
||||
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 if issue.fields.creator else "Unknown",
|
||||
"created": issue.fields.created,
|
||||
})
|
||||
|
||||
updated_jql = (
|
||||
f'project = {project_key} AND updated >= "{since_for_jql}" '
|
||||
f'AND created < "{since_for_jql}" ORDER BY updated DESC'
|
||||
)
|
||||
for issue in jira.search_issues(updated_jql, maxResults=50):
|
||||
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
|
||||
|
||||
return f"No changes detected in JIRA project {project_key} since {since_timestamp}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to watch JIRA project: %s", e)
|
||||
return f"Error watching JIRA project {project_key}: {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def jira_get_issue_info(
|
||||
issue_key: str,
|
||||
url: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Get detailed information about a specific JIRA issue."""
|
||||
try:
|
||||
_validate_issue_key(issue_key)
|
||||
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"
|
||||
assignee = issue.fields.assignee.displayName if issue.fields.assignee else "Unassigned"
|
||||
result += f"Assignee: {assignee}\n"
|
||||
reporter = issue.fields.reporter.displayName if issue.fields.reporter else "Unknown"
|
||||
result += f"Reporter: {reporter}\n"
|
||||
result += f"Created: {issue.fields.created}\n"
|
||||
result += f"Updated: {issue.fields.updated}\n"
|
||||
|
||||
if issue.fields.description:
|
||||
desc = issue.fields.description
|
||||
suffix = "..." if len(desc) > 500 else ""
|
||||
result += f"Description: {desc[:500]}{suffix}\n"
|
||||
|
||||
comments = jira.comments(issue_key)
|
||||
if comments:
|
||||
result += f"\nRecent Comments ({len(comments[-3:])}):\n"
|
||||
for comment in comments[-3:]:
|
||||
author = comment.author.displayName if comment.author else "Unknown"
|
||||
result += f" - {author} ({comment.created}): {comment.body[:200]}...\n"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get JIRA issue info: %s", e)
|
||||
return f"Error getting JIRA issue {issue_key}: {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def jira_search_issues(
|
||||
jql: str,
|
||||
url: Optional[str] = None,
|
||||
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)."""
|
||||
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:
|
||||
assignee = issue.fields.assignee.displayName if issue.fields.assignee else "Unassigned"
|
||||
result += f"{issue.key}: {issue.fields.summary}\n"
|
||||
result += f" Status: {issue.fields.status.name}\n"
|
||||
result += f" Assignee: {assignee}\n"
|
||||
result += f" Updated: {issue.fields.updated}\n\n"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to search JIRA issues: %s", e)
|
||||
return f"Error searching JIRA issues: {e}"
|
||||
|
||||
|
||||
def jira_tools():
|
||||
"""Return all JIRA watch tools as a collection for agent registration."""
|
||||
return [
|
||||
jira_watch_issue,
|
||||
jira_watch_project,
|
||||
jira_get_issue_info,
|
||||
jira_search_issues,
|
||||
]
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.29"
|
||||
version = "0.2.30"
|
||||
description = "Extended tools for PraisonAI Agents"
|
||||
authors = [
|
||||
{name = "Mervin Praison"}
|
||||
@@ -61,6 +61,9 @@ video-motion = [
|
||||
"playwright>=1.40",
|
||||
"imageio-ffmpeg>=0.5",
|
||||
]
|
||||
composio = [
|
||||
"composio>=0.5.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Unit tests for Composio tools."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.composio_tool import (
|
||||
ComposioTool,
|
||||
ComposioTools,
|
||||
_check_composio_available,
|
||||
composio_execute,
|
||||
composio_list_apps,
|
||||
composio_tools,
|
||||
)
|
||||
|
||||
|
||||
class TestCheckComposioAvailable:
|
||||
def test_missing_package(self):
|
||||
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=None):
|
||||
ok, msg = _check_composio_available()
|
||||
assert ok is False
|
||||
assert "not installed" in msg
|
||||
|
||||
def test_missing_api_key(self):
|
||||
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=MagicMock()):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
ok, msg = _check_composio_available()
|
||||
assert ok is False
|
||||
assert "COMPOSIO_API_KEY" in msg
|
||||
|
||||
def test_explicit_api_key_overrides_env(self):
|
||||
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=MagicMock()):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
ok, msg = _check_composio_available(api_key="explicit-key")
|
||||
assert ok is True
|
||||
assert msg is None
|
||||
|
||||
def test_available(self):
|
||||
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=MagicMock()):
|
||||
with patch.dict(os.environ, {"COMPOSIO_API_KEY": "test-key"}, clear=True):
|
||||
ok, msg = _check_composio_available()
|
||||
assert ok is True
|
||||
assert msg is None
|
||||
|
||||
|
||||
class TestComposioTools:
|
||||
def test_get_tools_unavailable_returns_empty(self):
|
||||
with patch(
|
||||
"praisonai_tools.tools.composio_tool._check_composio_available",
|
||||
return_value=(False, "missing"),
|
||||
):
|
||||
assert ComposioTools().get_tools(apps=["github"]) == []
|
||||
|
||||
def test_get_tools_new_sdk(self):
|
||||
mock_tool = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.tools.get.return_value = [mock_tool]
|
||||
|
||||
with patch(
|
||||
"praisonai_tools.tools.composio_tool._check_composio_available",
|
||||
return_value=(True, None),
|
||||
):
|
||||
tools = ComposioTools(api_key="k")
|
||||
with patch.object(tools, "_get_client", return_value=mock_client):
|
||||
result = tools.get_tools(apps=["github"])
|
||||
|
||||
assert result == [mock_tool]
|
||||
mock_client.tools.get.assert_called_once_with(user_id="default", toolkits=["github"])
|
||||
|
||||
def test_get_tools_new_sdk_explicit_user_id(self):
|
||||
mock_tool = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.tools.get.return_value = [mock_tool]
|
||||
|
||||
with patch(
|
||||
"praisonai_tools.tools.composio_tool._check_composio_available",
|
||||
return_value=(True, None),
|
||||
):
|
||||
tools = ComposioTools(api_key="k")
|
||||
with patch.object(tools, "_get_client", return_value=mock_client):
|
||||
result = tools.get_tools(apps=["github"], user_id="user-123")
|
||||
|
||||
assert result == [mock_tool]
|
||||
mock_client.tools.get.assert_called_once_with(user_id="user-123", toolkits=["github"])
|
||||
|
||||
def test_list_apps(self):
|
||||
app = MagicMock(key="github")
|
||||
mock_client = MagicMock()
|
||||
mock_client.apps.get.return_value = [app]
|
||||
|
||||
with patch(
|
||||
"praisonai_tools.tools.composio_tool._check_composio_available",
|
||||
return_value=(True, None),
|
||||
):
|
||||
tools = ComposioTools(api_key="k")
|
||||
with patch.object(tools, "_get_client", return_value=mock_client):
|
||||
assert tools.list_apps() == ["github"]
|
||||
|
||||
|
||||
class TestStandaloneFunctions:
|
||||
def test_composio_tools_delegates(self):
|
||||
with patch.object(ComposioTools, "get_tools", return_value=["t1"]) as mock_get:
|
||||
assert composio_tools(apps=["slack"]) == ["t1"]
|
||||
mock_get.assert_called_once_with(apps=["slack"], actions=None, tags=None, user_id=None)
|
||||
|
||||
def test_composio_list_apps_delegates(self):
|
||||
with patch.object(ComposioTools, "list_apps", return_value=["github"]) as mock_list:
|
||||
assert composio_list_apps() == ["github"]
|
||||
mock_list.assert_called_once_with()
|
||||
|
||||
|
||||
class TestComposioTool:
|
||||
def test_execute_requires_action_name(self):
|
||||
assert ComposioTool().execute(action_name="", params={}) == {"error": "action_name is required"}
|
||||
|
||||
def test_composio_execute_delegates(self):
|
||||
with patch.object(ComposioTool, "execute", return_value={"result": "ok"}) as mock_exec:
|
||||
assert composio_execute("GITHUB_STAR", {"repo": "x"}) == {"result": "ok"}
|
||||
mock_exec.assert_called_once_with(action_name="GITHUB_STAR", params={"repo": "x"})
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Unit tests for JIRA watch tools."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Optional dependency — stub so @patch("jira.JIRA") resolves in CI without jira installed.
|
||||
sys.modules.setdefault("jira", MagicMock())
|
||||
|
||||
from praisonai_tools.tools.jira_watch_tool import ( # noqa: E402
|
||||
jira_watch_issue,
|
||||
jira_watch_project,
|
||||
jira_get_issue_info,
|
||||
jira_search_issues,
|
||||
jira_tools,
|
||||
_get_jira_connection,
|
||||
_validate_project_key,
|
||||
_validate_issue_key,
|
||||
_validate_timestamp,
|
||||
)
|
||||
|
||||
|
||||
class TestJIRAConnection:
|
||||
@patch("jira.JIRA")
|
||||
def test_connection_with_email_token(self, mock_jira):
|
||||
_get_jira_connection(
|
||||
url="https://test.atlassian.net",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
)
|
||||
mock_jira.assert_called_once_with(
|
||||
server="https://test.atlassian.net",
|
||||
basic_auth=("[email protected]", "test_token"),
|
||||
)
|
||||
|
||||
@patch("jira.JIRA")
|
||||
def test_connection_with_username_token(self, mock_jira):
|
||||
_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, monkeypatch):
|
||||
for var in ("JIRA_EMAIL", "JIRA_USERNAME", "JIRA_API_TOKEN"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
with pytest.raises(ValueError, match="JIRA authentication required"):
|
||||
_get_jira_connection(url="https://test.atlassian.net")
|
||||
|
||||
def test_connection_missing_url(self, monkeypatch):
|
||||
monkeypatch.delenv("JIRA_URL", raising=False)
|
||||
with pytest.raises(ValueError, match="JIRA URL is required"):
|
||||
_get_jira_connection(email="[email protected]", token="token")
|
||||
|
||||
|
||||
class TestJIRAGetIssueInfo:
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_get_issue_info_success(self, mock_connection):
|
||||
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="[email protected]",
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
assert "PROJ-123" in result
|
||||
assert "Test issue" in result
|
||||
assert "Open" in result
|
||||
|
||||
|
||||
class TestJIRASearchIssues:
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_search_issues_success(self, mock_connection):
|
||||
mock_jira = Mock()
|
||||
mock_connection.return_value = mock_jira
|
||||
|
||||
mock_issue = Mock()
|
||||
mock_issue.key = "PROJ-123"
|
||||
mock_issue.fields.summary = "First issue"
|
||||
mock_issue.fields.status.name = "Open"
|
||||
mock_issue.fields.assignee.displayName = "John Doe"
|
||||
mock_issue.fields.updated = "2024-01-01T10:00:00"
|
||||
|
||||
mock_jira.search_issues.return_value = [mock_issue]
|
||||
|
||||
result = jira_search_issues(
|
||||
jql="project = PROJ",
|
||||
url="https://test.atlassian.net",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
assert "Found 1 issues" in result
|
||||
assert "PROJ-123" in result
|
||||
|
||||
|
||||
class TestJIRAWatchIssue:
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_issue_current_state(self, mock_connection):
|
||||
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="[email protected]",
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
assert "current state" in result
|
||||
assert "PROJ-123" in result
|
||||
|
||||
|
||||
class TestJIRAWatchProject:
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_project_recent_activity(self, mock_connection):
|
||||
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.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="[email protected]",
|
||||
token="test_token",
|
||||
)
|
||||
|
||||
assert "recent activity" in result
|
||||
assert "PROJ-123" in result
|
||||
|
||||
|
||||
class TestJIRAValidation:
|
||||
def test_validate_project_key_valid(self):
|
||||
assert _validate_project_key("PROJ")
|
||||
assert _validate_project_key("MY_PROJECT")
|
||||
|
||||
def test_validate_project_key_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_project_key("proj")
|
||||
with pytest.raises(ValueError):
|
||||
_validate_project_key("PROJ OR 1=1")
|
||||
|
||||
def test_validate_issue_key_valid(self):
|
||||
assert _validate_issue_key("PROJ-123")
|
||||
assert _validate_issue_key("abc_123")
|
||||
|
||||
def test_validate_issue_key_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_issue_key("../secret")
|
||||
with pytest.raises(ValueError):
|
||||
_validate_issue_key('PROJ-1" OR "1"="1')
|
||||
|
||||
def test_validate_timestamp_valid(self):
|
||||
assert _validate_timestamp("2024-01-01T00:00:00Z") == "2024-01-01 00:00"
|
||||
assert _validate_timestamp("2024-01-01T10:30:00") == "2024-01-01 10:30"
|
||||
|
||||
def test_validate_timestamp_rejects_injection(self):
|
||||
with pytest.raises(ValueError):
|
||||
_validate_timestamp('2024-01-01" OR project = SECRET AND created >= "2024-01-01')
|
||||
|
||||
|
||||
class TestJIRAWatchSinceTimestamp:
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_issue_no_change(self, mock_connection):
|
||||
mock_jira = Mock()
|
||||
mock_connection.return_value = mock_jira
|
||||
mock_issue = Mock()
|
||||
mock_issue.fields.updated = "2024-01-01T10:00:00+00:00"
|
||||
mock_issue.fields.status.name = "Open"
|
||||
mock_issue.fields.summary = "Test issue"
|
||||
mock_issue.changelog.histories = []
|
||||
mock_jira.issue.return_value = mock_issue
|
||||
|
||||
result = jira_watch_issue(
|
||||
issue_key="PROJ-123",
|
||||
url="https://test.atlassian.net",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
since_timestamp="2024-06-01T00:00:00Z",
|
||||
)
|
||||
assert "No changes detected" in result
|
||||
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_issue_change_detected(self, mock_connection):
|
||||
mock_jira = Mock()
|
||||
mock_connection.return_value = mock_jira
|
||||
mock_issue = Mock()
|
||||
mock_issue.fields.updated = "2024-06-15T10:00:00+00:00"
|
||||
mock_issue.fields.status.name = "Done"
|
||||
mock_issue.fields.summary = "Test issue"
|
||||
mock_issue.fields.assignee.displayName = "John Doe"
|
||||
mock_issue.fields.priority.name = "High"
|
||||
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",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
since_timestamp="2024-01-01T00:00:00Z",
|
||||
)
|
||||
assert "changes detected" in result
|
||||
assert "PROJ-123" in result
|
||||
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_issue_reports_all_in_window_changes(self, mock_connection):
|
||||
mock_jira = Mock()
|
||||
mock_connection.return_value = mock_jira
|
||||
mock_issue = Mock()
|
||||
mock_issue.fields.updated = "2024-06-15T10:00:00+00:00"
|
||||
mock_issue.fields.status.name = "Done"
|
||||
mock_issue.fields.summary = "Test issue"
|
||||
mock_issue.fields.assignee.displayName = "John Doe"
|
||||
mock_issue.fields.priority.name = "High"
|
||||
|
||||
histories = []
|
||||
for idx in range(5):
|
||||
hist = Mock()
|
||||
hist.created = f"2024-06-1{idx}T10:00:00+00:00"
|
||||
hist.author.displayName = "Editor"
|
||||
item = Mock()
|
||||
item.field = f"field{idx}"
|
||||
item.fromString = "old"
|
||||
item.toString = "new"
|
||||
hist.items = [item]
|
||||
histories.append(hist)
|
||||
mock_issue.changelog.histories = 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",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
since_timestamp="2024-01-01T00:00:00Z",
|
||||
)
|
||||
for idx in range(5):
|
||||
assert f"field{idx}" in result
|
||||
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_project_since_timestamp_no_injection(self, mock_connection):
|
||||
mock_jira = Mock()
|
||||
mock_connection.return_value = mock_jira
|
||||
mock_jira.search_issues.return_value = []
|
||||
|
||||
result = jira_watch_project(
|
||||
project_key="PROJ",
|
||||
url="https://test.atlassian.net",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
since_timestamp="2024-01-01T00:00:00Z",
|
||||
)
|
||||
assert "No changes detected" in result
|
||||
for call in mock_jira.search_issues.call_args_list:
|
||||
assert '2024-01-01 00:00' in call.args[0]
|
||||
|
||||
@patch("praisonai_tools.tools.jira_watch_tool._get_jira_connection")
|
||||
def test_watch_project_rejects_bad_timestamp(self, mock_connection):
|
||||
mock_connection.return_value = Mock()
|
||||
result = jira_watch_project(
|
||||
project_key="PROJ",
|
||||
url="https://test.atlassian.net",
|
||||
email="[email protected]",
|
||||
token="test_token",
|
||||
since_timestamp='bad" OR "1"="1',
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
def test_jira_tools_collection():
|
||||
tools = jira_tools()
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 4
|
||||
Reference in new issue
Block a user