Compare commits

...
Author SHA1 Message Date
Mervin Praison d915d18961 fix(release): align version with PyPI and skip Release/Bump loops (#48)
* fix(release): align version with PyPI and skip Release/Bump loops

Sync pyproject to 0.2.30 (already on PyPI) so the next package change
can patch-release. Skip nightly gate on Release/Bump commits like monorepo.

* fix(release): skip Release/Bump commits in nightly gate
2026-07-09 10:25:03 +01:00
Mervin Praison 45c8c1d98f fix(merge-gate): raise Tools auto-merge size limit via gate-config (#47)
Tool + tests + docs PRs routinely exceed the 800-line UI default.
Make limits config-driven (2000 lines) and stop treating pyproject.toml
as a hard sensitive path so optional tool deps can auto-merge.
2026-07-09 10:03:27 +01:00
18b4b0c4d4 fix(merge-gate): clear stale conflict block when PR is mergeable (#46)
* fix(merge-gate): clear stale conflict block when PR is mergeable

Ignore old merge-conflict @claude triggers once HEAD is after the
conflict comment and merge state is CLEAN/UNSTABLE, and accept owner
rebase-complete comments so manual recoveries unblock auto-merge.

* fix(merge-gate): guard reduce with null seed in conflict bypass

Address Greptile review: the CLEAN/UNSTABLE conflict-clear bypass used
.reduce() without an initial value and a length>0 guard that could
silently fall through to `return true`. Seed reduce with null and add an
explicit null check so the non-empty invariant is explicit and safe if a
future refactor changes it.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 03:09:02 +01:00
2d79dcbb01 feat: add JIRA watch tools for issue and project monitoring (#43)
* feat: add JIRA watch tools for issue and project monitoring

Port JIRA watch functionality from PraisonAI PR #1875 to the tools package
where external integrations belong.

Co-authored-by: Cursor <[email protected]>

* Stub optional jira module in watch tool unit tests for CI.

* Allow module stub before import in JIRA watch tests.

* fix(jira): sanitize inputs, safe timestamp comparison, guard None fields

- Validate since_timestamp and issue_key to prevent JQL injection / abuse
- Compare timestamps as tz-aware datetimes instead of lexicographic strings
- Guard None derefs on history/comment author, creator, reporter
- Fix recent-activity count/display mismatch and description truncation suffix
- Harden env-isolated auth test; add since_timestamp + validation tests

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix(jira): detect all in-window changelog entries; isolate URL test

- jira_watch_issue: filter changelog by since_dt over the full history
  instead of slicing to the last 3 entries first (fixes silently dropped
  in-window field changes); cap display to 10 with a total count.
- test_connection_missing_url: clear JIRA_URL via monkeypatch to avoid
  CI flakiness when the var is set.
- Add test asserting all in-window field changes are reported.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-07-08 23:27:31 +01:00
b8d64b432c feat: Composio agent-callable tools (#44)
* feat: port Composio agent-callable tools from core SDK

Add composio_tools(), composio_list_apps(), ComposioTools class,
[composio] optional extra, tests, and README usage docs.

* fix(composio): honor explicit api_key and use v3 toolkits API

- _check_composio_available() now accepts an explicit api_key and uses it
  as a fallback, fixing a broken contract where a key passed to
  composio_tools()/ComposioTools was silently ignored.
- Availability check requires the `composio` package only, removing the
  false-positive composio_openai branch that led to silent ImportError.
- get_tools() maps to the Composio v3 SDK (`toolkits`, required `user_id`)
  for the new client while preserving legacy ComposioToolSet behaviour.
- Remove redundant availability check inside _get_client().
- list_apps() handles string app entries defensively.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix(composio): never use api_key as user_id fallback in v3 SDK

The API key is an auth credential, not an entity identifier. Fall back
to "default" only. Add test for explicit user_id and correct the v3
call assertion.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-07-08 22:45:35 +01:00
13 changed files with 1117 additions and 32 deletions

No files matched your search

+4 -1
View File
@@ -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',
+32 -2
View File
@@ -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 } } },
+27 -5
View File
@@ -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)) {
+3 -1
View File
@@ -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:
+23
View File
@@ -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:
+55
View File
@@ -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()
+6
View File
@@ -144,4 +144,10 @@ __all__ = [
"AgentFolioTool",
"check_behavioral_trust",
"verify_task_delegation_safety",
# Composio
"ComposioTool",
"ComposioTools",
"composio_execute",
"composio_tools",
"composio_list_apps",
]
+13
View File
@@ -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",
+165 -22
View File
@@ -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)
+353
View File
@@ -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
View File
@@ -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"
+118
View File
@@ -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"})
+314
View File
@@ -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