Compare commits

...
Author SHA1 Message Date
MervinPraison f21fca919d Release v0.2.31 2026-07-09 13:31:47 +00:00
Mervin Praison 70d8bc9bf6 Merge pull request #40 from MervinPraison/claude/issue-1716-20260522
feat: Add NEXUS Prediction Market Tool Integration
2026-07-09 14:30:43 +01:00
Mervin Praison 1e08305c80 fix(release): stop failing on gitignored uv.lock (#49)
git add uv.lock exits 1 under bash -e and aborts the release before
publish. Only commit pyproject.toml (uv.lock is intentionally ignored).
2026-07-09 13:52:13 +01:00
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
MervinPraisonandMervin Praison f404b2e903 fix(nexus): resolve Greptile P1 issues in exports and tests
- Remove get_market_data / check_arbitrage_opportunities from tool_map
  and __all__ in tools/__init__.py; they are instance methods, not
  module-level names, so importing them raised AttributeError and
  polluted the package namespace with generic identifiers.
- Fix two failure-path tests that raised a plain Exception (never caught
  by the tool). Now raise requests.exceptions.ConnectionError and a
  proper HTTPError with response.status_code=402 to exercise the real
  error-handling branches.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-08 22:32:09 +00: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
MervinPraisonandpraisonai-triage-agent[bot] 8a47aff40a feat: add NEXUS prediction market tool integration (fixes MervinPraison/PraisonAI#1716)
- Added NexusPredictionMarketTool for live Kalshi/Polymarket data access
- Supports free market data queries and paid arbitrage analysis
- Includes comprehensive examples and documentation
- Full test coverage with mocked API responses
- Integrated with PraisonAI agent workflow patterns

Features:
- Real-time prediction market data from NEXUS API
- Arbitrage opportunity detection (paid feature: /bin/bash.02 USDC)
- Agent metadata and capabilities discovery
- Source tracking via X-NEXUS-Source header
- Error handling for network/payment issues

API Endpoints:
- Free: /kalshi?market={market_name}
- Paid: /arb/check?markets={market1,market2}
- Validation: https://agentic.market/validate

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-08 14:39:21 +01:00
18 changed files with 2201 additions and 33 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:
+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
VERSION: ${{ steps.versions.outputs.new_version }}
run: |
TAG="v${VERSION}"
git add pyproject.toml uv.lock
git add pyproject.toml
git commit -m "Release ${TAG}" || exit 0
git tag -f "${TAG}"
git pull --rebase origin main
+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:
+382
View File
@@ -0,0 +1,382 @@
# NEXUS Prediction Market Integration
This document provides comprehensive information about integrating the NEXUS prediction market API with PraisonAI agents.
## Overview
NEXUS provides live Kalshi/Polymarket prediction market data and arbitrage opportunities through a validated API. The integration allows PraisonAI agents to:
- Access real-time prediction market data
- Identify arbitrage opportunities between markets
- Analyze market sentiment and trends
- Build sophisticated trading and analysis workflows
## API Information
### Endpoints
- **Free Market Data**: `/kalshi?market={market_name}`
- **Paid Arbitrage Analysis**: `/arb/check?markets={market1,market2}` ($0.02 USDC on eip155:8453)
- **Agent Metadata**: `/.well-known/agent.json`
### Validation
The API is fully validated on Agentic Market:
- **Validation URL**: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
- **API Card**: https://nexus-agent-xa12.onrender.com/.well-known/agent.json
### Usage Tracking
Include the `X-NEXUS-Source` header to track per-source call volume:
```
X-NEXUS-Source: your-bot-name
```
## Installation
```bash
pip install praisonai-tools
```
## Quick Start
### Basic Usage
```python
from praisonai_tools.tools import NexusPredictionMarketTool
# Initialize the tool
nexus = NexusPredictionMarketTool(source_name="my-agent")
# Get market data
fed_data = nexus.get_market_data("Fed")
print(fed_data)
# Check arbitrage opportunities (paid)
arb_data = nexus.check_arbitrage_opportunities(["Fed", "BTC"])
print(arb_data)
```
### Agent Integration
```python
from praisonaiagents import Agent, tool
from praisonai_tools.tools import nexus_prediction_market_tool
@tool
def get_market_data(market: str) -> str:
"""Get prediction market data."""
return nexus_prediction_market_tool("get_market_data", market=market)
agent = Agent(
name="MarketAnalyst",
tools=[get_market_data],
instructions="Analyze prediction markets using NEXUS data."
)
response = agent.start("What do Fed rate predictions show?")
```
## Features
### Free Features
1. **Market Data Queries**
- Real-time price data
- Volume and liquidity information
- Market sentiment indicators
- Historical trends
2. **Agent Metadata**
- API capabilities and endpoints
- Service status and version info
- Usage guidelines
### Paid Features
1. **Arbitrage Analysis** ($0.02 USDC on eip155:8453)
- Cross-market opportunity detection
- Profit potential calculations
- Risk assessment
- Execution recommendations
## Supported Markets
Common market identifiers include:
- `Fed` - Federal Reserve interest rate predictions
- `BTC` - Bitcoin price predictions
- `Election` - Political election outcomes
- `ETH` - Ethereum price predictions
- `Inflation` - Inflation rate predictions
*Note: Market availability may vary. Check current markets via the API.*
## API Reference
### NexusPredictionMarketTool Class
```python
class NexusPredictionMarketTool:
def __init__(self, source_name: Optional[str] = None, api_key: Optional[str] = None)
def get_market_data(self, market: str) -> Dict[str, Any]
def check_arbitrage_opportunities(self, markets: List[str]) -> Dict[str, Any]
def get_agent_info(self) -> Dict[str, Any]
def run(self, action: str, market: Optional[str] = None, markets: Optional[List[str]] = None) -> str
```
### Function Interface
```python
def nexus_prediction_market_tool(
action: str,
market: Optional[str] = None,
markets: Optional[List[str]] = None,
source_name: Optional[str] = None
) -> str
```
## Examples
### Market Analysis Agent
```python
from praisonaiagents import Agent
from praisonai_tools.tools import NexusPredictionMarketTool
class MarketAnalysisAgent:
def __init__(self):
self.nexus = NexusPredictionMarketTool(source_name="analysis-bot")
def analyze_market_sentiment(self, market):
"""Analyze market sentiment for a specific prediction market."""
data = self.nexus.get_market_data(market)
if data["success"]:
market_data = data["data"]
# Analyze price, volume, trends etc.
return f"Market sentiment for {market}: {self.interpret_data(market_data)}"
else:
return f"Error getting data: {data['error']}"
def interpret_data(self, data):
"""Custom logic to interpret market data."""
# Implementation specific to your analysis needs
pass
```
### Multi-Agent Workflow
```python
from praisonaiagents import Agent, AgentTeam, Task
# Data collector agent
collector = Agent(
name="DataCollector",
tools=[get_market_data],
instructions="Collect prediction market data from NEXUS."
)
# Analyst agent
analyst = Agent(
name="Analyst",
instructions="Analyze market data and provide insights."
)
# Create workflow
team = AgentTeam(agents=[collector, analyst])
# Define tasks
collect_task = Task(
name="collect_data",
description="Collect data for Fed and BTC markets",
agent=collector
)
analyze_task = Task(
name="analyze_trends",
description="Analyze the collected market data",
agent=analyst,
dependencies=[collect_task]
)
# Execute workflow
results = team.run(tasks=[collect_task, analyze_task])
```
## Error Handling
The tool provides comprehensive error handling for common scenarios:
### Network Errors
```python
{
"success": False,
"error": "Failed to fetch market data: Connection timeout",
"market": "Fed"
}
```
### Payment Required (Arbitrage Features)
```python
{
"success": False,
"error": "Payment required for arbitrage analysis. Cost: $0.02 USDC on eip155:8453",
"markets": ["Fed", "BTC"],
"payment_info": "This feature requires payment on eip155:8453 blockchain"
}
```
### Invalid Parameters
```python
{
"success": False,
"error": "Market parameter required for get_market_data action"
}
```
## Best Practices
### 1. Source Identification
Always set a descriptive source name for tracking:
```python
nexus = NexusPredictionMarketTool(source_name="trading-bot-v1")
```
### 2. Error Handling
Always check the `success` field before processing results:
```python
result = nexus.get_market_data("Fed")
if result["success"]:
data = result["data"]
# Process data
else:
print(f"Error: {result['error']}")
```
### 3. Rate Limiting
Implement appropriate delays between requests to avoid overwhelming the API:
```python
import time
import asyncio
async def batch_collect_data(markets):
data = {}
for market in markets:
result = nexus.get_market_data(market)
data[market] = result
await asyncio.sleep(1) # 1 second delay between requests
return data
```
### 4. Caching
Cache results for frequently accessed data:
```python
from functools import lru_cache
import time
@lru_cache(maxsize=100)
def cached_market_data(market, timestamp):
return nexus.get_market_data(market)
def get_cached_data(market, cache_duration=300): # 5 minutes
timestamp = int(time.time() // cache_duration)
return cached_market_data(market, timestamp)
```
## Security Considerations
### 1. API Key Management
If using paid features, securely manage your API keys:
```python
import os
api_key = os.getenv("NEXUS_API_KEY")
nexus = NexusPredictionMarketTool(api_key=api_key)
```
### 2. Input Validation
Validate market identifiers before making requests:
```python
VALID_MARKETS = ["Fed", "BTC", "ETH", "Election", "Inflation"]
def validate_market(market):
if market not in VALID_MARKETS:
raise ValueError(f"Invalid market: {market}")
return market
```
### 3. Rate Limiting
Implement client-side rate limiting to prevent abuse:
```python
from time import time, sleep
class RateLimiter:
def __init__(self, max_calls_per_minute=60):
self.max_calls = max_calls_per_minute
self.calls = []
def wait_if_needed(self):
now = time()
self.calls = [call_time for call_time in self.calls if now - call_time < 60]
if len(self.calls) >= self.max_calls:
sleep(1)
self.calls.append(now)
```
## Troubleshooting
### Common Issues
1. **Connection Timeout**
- Check internet connectivity
- Verify the API endpoint is accessible
- Implement retry logic with exponential backoff
2. **Invalid Market Identifier**
- Verify the market name is correct
- Check available markets via agent info endpoint
- Use exact case-sensitive market names
3. **Payment Required Errors**
- Ensure you have sufficient USDC balance on eip155:8453
- Verify payment setup for arbitrage features
- Contact NEXUS support if payment issues persist
4. **JSON Decode Errors**
- Check API response format
- Verify the endpoint returned valid JSON
- Implement fallback error handling
### Debug Mode
Enable debug logging to troubleshoot issues:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
nexus = NexusPredictionMarketTool(source_name="debug-agent")
# Requests will now be logged with full details
```
## Support and Resources
- **API Documentation**: https://nexus-agent-xa12.onrender.com/.well-known/agent.json
- **Validation Check**: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
- **PraisonAI Documentation**: https://docs.praison.ai
- **Issue Tracking**: Report issues via GitHub issues in the PraisonAI-Tools repository
## Contributing
To contribute improvements or report bugs:
1. Fork the PraisonAI-Tools repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Submit a pull request
## License
This tool is part of the PraisonAI-Tools package and follows the same licensing terms.
+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()
+198
View File
@@ -0,0 +1,198 @@
"""Example: Using NEXUS Prediction Market Tool with PraisonAI
This example demonstrates how to use the NEXUS prediction market tool to:
1. Get free market data for prediction markets
2. Check arbitrage opportunities between markets (paid feature)
3. Build an intelligent prediction market analysis agent
NEXUS API Information:
- Free endpoint: /kalshi?market={market_name}
- Paid endpoint: /arb/check?markets={market1,market2} ($0.02 USDC on eip155:8453)
- Validation: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
"""
from praisonaiagents import Agent, tool
from praisonai_tools.tools.nexus_prediction_market_tool import NexusPredictionMarketTool
import json
# Example 1: Direct tool usage
def example_direct_usage():
"""Example of using the NEXUS tool directly."""
print("=== Direct NEXUS Tool Usage ===\n")
# Initialize the tool
nexus_tool = NexusPredictionMarketTool(source_name="praisonai-example")
# Get market data for Fed predictions
print("1. Getting Fed market data...")
fed_data = nexus_tool.get_market_data("Fed")
print(json.dumps(fed_data, indent=2))
print()
# Get BTC market data
print("2. Getting BTC market data...")
btc_data = nexus_tool.get_market_data("BTC")
print(json.dumps(btc_data, indent=2))
print()
# Check arbitrage opportunities (paid feature)
print("3. Checking arbitrage opportunities between Fed and BTC markets...")
arb_data = nexus_tool.check_arbitrage_opportunities(["Fed", "BTC"])
print(json.dumps(arb_data, indent=2))
print()
# Get agent information
print("4. Getting NEXUS agent information...")
agent_info = nexus_tool.get_agent_info()
print(json.dumps(agent_info, indent=2))
# Example 2: Create a custom tool wrapper for agents
@tool
def get_prediction_market_data(market: str) -> str:
"""Get prediction market data for a specific market.
Args:
market: Market identifier (e.g., 'Fed', 'BTC', 'Election')
Returns:
JSON string with market data including prices and trends
"""
nexus_tool = NexusPredictionMarketTool(source_name="praisonai-agent")
return nexus_tool.run(action="get_market_data", market=market)
@tool
def check_prediction_market_arbitrage(markets: str) -> str:
"""Check for arbitrage opportunities between prediction markets.
Args:
markets: Comma-separated list of markets to analyze (e.g., 'Fed,BTC')
Returns:
JSON string with arbitrage opportunities and analysis
Note:
This is a paid feature requiring $0.02 USDC on eip155:8453
"""
markets_list = [m.strip() for m in markets.split(',')]
nexus_tool = NexusPredictionMarketTool(source_name="praisonai-agent")
return nexus_tool.run(action="check_arbitrage", markets=markets_list)
# Example 3: Prediction market analysis agent
def example_prediction_market_agent():
"""Example of a prediction market analysis agent."""
print("\n=== Prediction Market Analysis Agent ===\n")
# Create an agent with NEXUS prediction market tools
market_agent = Agent(
name="PredictionMarketAnalyst",
instructions="""You are an expert prediction market analyst.
Your capabilities:
- Analyze prediction market data from Kalshi and Polymarket via NEXUS API
- Identify market trends and sentiment
- Spot potential arbitrage opportunities
- Provide insights on market dynamics
When analyzing markets:
1. First get current market data
2. Analyze the data for trends and insights
3. Look for arbitrage opportunities if requested
4. Provide clear explanations of your findings
Be thorough in your analysis and cite specific data points.""",
tools=[get_prediction_market_data, check_prediction_market_arbitrage],
llm="gpt-4o-mini"
)
# Example queries
queries = [
"Get the current Fed interest rate prediction market data and analyze what it shows about market sentiment.",
"Compare Fed and BTC prediction markets. What insights can you derive?",
"Check if there are any arbitrage opportunities between Fed and BTC markets."
]
for i, query in enumerate(queries, 1):
print(f"Query {i}: {query}")
response = market_agent.start(query)
print(f"Response: {response}\n")
print("-" * 80)
# Example 4: Multi-agent prediction market workflow
def example_multi_agent_workflow():
"""Example of a multi-agent workflow for comprehensive market analysis."""
print("\n=== Multi-Agent Prediction Market Workflow ===\n")
# Data gathering agent
data_agent = Agent(
name="MarketDataCollector",
instructions="""You collect prediction market data from NEXUS API.
Focus on gathering comprehensive data for analysis.""",
tools=[get_prediction_market_data]
)
# Analysis agent
analysis_agent = Agent(
name="MarketAnalyst",
instructions="""You analyze prediction market data to identify trends,
sentiment, and trading opportunities. Provide detailed insights.""",
tools=[]
)
# Arbitrage agent
arbitrage_agent = Agent(
name="ArbitrageSpecialist",
instructions="""You specialize in finding arbitrage opportunities
between prediction markets. Assess risk and profitability.""",
tools=[check_prediction_market_arbitrage]
)
# Example workflow
markets = ["Fed", "BTC", "Election"]
print("Step 1: Collecting market data...")
market_data = {}
for market in markets:
print(f"Collecting data for {market} market...")
data = data_agent.start(f"Get current market data for {market}")
market_data[market] = data
print(f"Data collected for {market}: {len(data)} characters")
print("\nStep 2: Analyzing market trends...")
combined_data = "\n".join([f"{market}: {data}" for market, data in market_data.items()])
analysis = analysis_agent.start(f"Analyze this prediction market data and identify key trends:\n{combined_data}")
print(f"Analysis: {analysis[:200]}...")
print("\nStep 3: Checking arbitrage opportunities...")
arb_analysis = arbitrage_agent.start("Check for arbitrage opportunities between Fed,BTC markets")
print(f"Arbitrage analysis: {arb_analysis[:200]}...")
if __name__ == "__main__":
print("NEXUS Prediction Market Tool Examples")
print("=" * 50)
# Run examples
try:
example_direct_usage()
example_prediction_market_agent()
example_multi_agent_workflow()
except Exception as e:
print(f"Error running examples: {e}")
print("\nNote: Some features require API access or payment.")
print("Free features: Market data queries")
print("Paid features: Arbitrage analysis ($0.02 USDC on eip155:8453)")
print("\n" + "=" * 50)
print("Examples completed!")
print("\nTo use in your own projects:")
print("1. Install: pip install praisonai-tools")
print("2. Import: from praisonai_tools.tools import NexusPredictionMarketTool")
print("3. Use: tool = NexusPredictionMarketTool()")
print("\nFor arbitrage features, ensure you have the required payment setup.")
+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",
]
+19
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",
@@ -537,6 +545,9 @@ def __getattr__(name):
"N8nWorkflowTool": "praisonai_tools.n8n.n8n_workflow",
"n8n_workflow": "praisonai_tools.n8n.n8n_workflow",
"n8n_list_workflows": "praisonai_tools.n8n.n8n_workflow",
# NEXUS Prediction Market Tool
"NexusPredictionMarketTool": "nexus_prediction_market_tool",
"nexus_prediction_market_tool": "nexus_prediction_market_tool",
}
if name in tool_map:
@@ -681,6 +692,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",
@@ -882,4 +898,7 @@ __all__ = [
"LangExtractTool",
"langextract_extract",
"langextract_render_file",
# NEXUS Prediction Market Tool
"NexusPredictionMarketTool",
"nexus_prediction_market_tool",
]
+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,
]
@@ -0,0 +1,253 @@
"""NEXUS Prediction Market Tool for PraisonAI
Provides access to live Kalshi/Polymarket prediction market data and arbitrage opportunities
through the NEXUS API (https://nexus-agent-xa12.onrender.com).
Features:
- Free market data queries
- Paid arbitrage opportunity detection
- Real-time prediction market insights
API Documentation:
- Free endpoint: /kalshi?market={market_name}
- Paid endpoint: /arb/check?markets={market1,market2}
- Validation: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
"""
from typing import Optional, List, Dict, Any
import requests
import json
from praisonai_tools.tools.base import BaseTool
class NexusPredictionMarketTool(BaseTool):
"""Tool for accessing NEXUS prediction market data and arbitrage opportunities."""
name: str = "nexus_prediction_market"
description: str = """Access live Kalshi/Polymarket prediction market data and arbitrage opportunities.
Features:
- Query free market data for specific markets (Fed, BTC, etc.)
- Check arbitrage opportunities between prediction markets (requires payment)
- Get real-time market insights and pricing data
Use cases:
- Market research and analysis
- Arbitrage opportunity identification
- Prediction market trend monitoring
"""
def __init__(self, source_name: Optional[str] = None, api_key: Optional[str] = None):
"""Initialize the NEXUS Prediction Market Tool.
Args:
source_name: Optional source identifier for tracking (sent as X-NEXUS-Source header)
api_key: Optional API key for paid features (if implemented)
"""
super().__init__()
self.base_url = "https://nexus-agent-xa12.onrender.com"
self.source_name = source_name or "praisonai-agent"
self.api_key = api_key
self.headers = {
"X-NEXUS-Source": self.source_name,
"User-Agent": "PraisonAI-NEXUS-Tool/1.0",
"Accept": "application/json"
}
if self.api_key:
self.headers["Authorization"] = f"Bearer {self.api_key}"
def get_market_data(self, market: str) -> Dict[str, Any]:
"""Get free market data for a specific prediction market.
Args:
market: Market identifier (e.g., 'Fed', 'BTC', 'Election')
Returns:
Dict containing market data including prices, volumes, and trends
"""
try:
url = f"{self.base_url}/kalshi"
params = {"market": market}
response = requests.get(url, params=params, headers=self.headers, timeout=30)
response.raise_for_status()
return {
"success": True,
"market": market,
"data": response.json(),
"source": "nexus-free-api"
}
except requests.RequestException as e:
return {
"success": False,
"error": f"Failed to fetch market data: {str(e)}",
"market": market
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON response: {str(e)}",
"market": market
}
def check_arbitrage_opportunities(self, markets: List[str]) -> Dict[str, Any]:
"""Check for arbitrage opportunities between prediction markets (paid feature).
Args:
markets: List of market identifiers to analyze for arbitrage
Returns:
Dict containing arbitrage opportunities and analysis
Note:
This is a paid feature requiring $0.02 USDC on eip155:8453
"""
try:
url = f"{self.base_url}/arb/check"
markets_param = ",".join(markets)
params = {"markets": markets_param}
response = requests.get(url, params=params, headers=self.headers, timeout=30)
response.raise_for_status()
return {
"success": True,
"markets": markets,
"arbitrage_data": response.json(),
"source": "nexus-paid-api",
"note": "This is a paid feature ($0.02 USDC on eip155:8453)"
}
except requests.RequestException as e:
if e.response and e.response.status_code == 402:
return {
"success": False,
"error": "Payment required for arbitrage analysis. Cost: $0.02 USDC on eip155:8453",
"markets": markets,
"payment_info": "This feature requires payment on eip155:8453 blockchain"
}
return {
"success": False,
"error": f"Failed to check arbitrage opportunities: {str(e)}",
"markets": markets
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON response: {str(e)}",
"markets": markets
}
def get_agent_info(self) -> Dict[str, Any]:
"""Get NEXUS agent information and capabilities.
Returns:
Dict containing agent metadata and API information
"""
try:
url = f"{self.base_url}/.well-known/agent.json"
response = requests.get(url, headers=self.headers, timeout=30)
response.raise_for_status()
return {
"success": True,
"agent_info": response.json(),
"source": "nexus-agent-metadata"
}
except requests.RequestException as e:
return {
"success": False,
"error": f"Failed to fetch agent info: {str(e)}"
}
except json.JSONDecodeError as e:
return {
"success": False,
"error": f"Invalid JSON response: {str(e)}"
}
def run(self,
action: str,
market: Optional[str] = None,
markets: Optional[List[str]] = None) -> str:
"""Execute NEXUS prediction market operations.
Args:
action: Action to perform ('get_market_data', 'check_arbitrage', 'get_agent_info')
market: Single market identifier (for get_market_data)
markets: List of markets (for check_arbitrage)
Returns:
JSON string with operation results
"""
try:
if action == "get_market_data":
if not market:
return json.dumps({
"success": False,
"error": "Market parameter required for get_market_data action"
})
result = self.get_market_data(market)
elif action == "check_arbitrage":
if not markets or not isinstance(markets, list):
return json.dumps({
"success": False,
"error": "Markets parameter (list) required for check_arbitrage action"
})
result = self.check_arbitrage_opportunities(markets)
elif action == "get_agent_info":
result = self.get_agent_info()
else:
return json.dumps({
"success": False,
"error": f"Unknown action: {action}. Available actions: get_market_data, check_arbitrage, get_agent_info"
})
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({
"success": False,
"error": f"Unexpected error: {str(e)}"
})
# For backward compatibility and ease of use
def nexus_prediction_market_tool(
action: str,
market: Optional[str] = None,
markets: Optional[List[str]] = None,
source_name: Optional[str] = None
) -> str:
"""Function-based interface for NEXUS prediction market operations.
Args:
action: Action to perform ('get_market_data', 'check_arbitrage', 'get_agent_info')
market: Single market identifier (for get_market_data)
markets: List of markets (for check_arbitrage)
source_name: Optional source identifier for tracking
Returns:
JSON string with operation results
Examples:
# Get Fed market data
result = nexus_prediction_market_tool("get_market_data", market="Fed")
# Check arbitrage between Fed and BTC markets
result = nexus_prediction_market_tool("check_arbitrage", markets=["Fed", "BTC"])
# Get agent info
result = nexus_prediction_market_tool("get_agent_info")
"""
tool = NexusPredictionMarketTool(source_name=source_name)
return tool.run(action=action, market=market, markets=markets)
__all__ = ["NexusPredictionMarketTool", "nexus_prediction_market_tool"]
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-tools"
version = "0.2.29"
version = "0.2.31"
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"
+244
View File
@@ -0,0 +1,244 @@
"""Tests for NEXUS Prediction Market Tool"""
import pytest
import json
import requests
from unittest.mock import Mock, patch
from praisonai_tools.tools.nexus_prediction_market_tool import (
NexusPredictionMarketTool,
nexus_prediction_market_tool
)
class TestNexusPredictionMarketTool:
"""Test cases for NEXUS Prediction Market Tool"""
def test_tool_initialization(self):
"""Test tool initialization with default and custom parameters."""
# Default initialization
tool = NexusPredictionMarketTool()
assert tool.name == "nexus_prediction_market"
assert tool.source_name == "praisonai-agent"
assert "X-NEXUS-Source" in tool.headers
assert tool.base_url == "https://nexus-agent-xa12.onrender.com"
# Custom initialization
custom_tool = NexusPredictionMarketTool(
source_name="test-bot",
api_key="test-key"
)
assert custom_tool.source_name == "test-bot"
assert custom_tool.headers["X-NEXUS-Source"] == "test-bot"
assert "Authorization" in custom_tool.headers
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
def test_get_market_data_success(self, mock_get):
"""Test successful market data retrieval."""
# Mock successful response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"market": "Fed",
"price": 0.75,
"volume": 1000000,
"trend": "bullish"
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
tool = NexusPredictionMarketTool()
result = tool.get_market_data("Fed")
assert result["success"] is True
assert result["market"] == "Fed"
assert "data" in result
assert result["source"] == "nexus-free-api"
# Verify request was made correctly
mock_get.assert_called_once()
args, kwargs = mock_get.call_args
assert "kalshi" in args[0]
assert kwargs["params"]["market"] == "Fed"
assert "X-NEXUS-Source" in kwargs["headers"]
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
def test_get_market_data_failure(self, mock_get):
"""Test market data retrieval failure."""
# Mock failed response
mock_get.side_effect = requests.exceptions.ConnectionError("Network error")
tool = NexusPredictionMarketTool()
result = tool.get_market_data("Fed")
assert result["success"] is False
assert "error" in result
assert result["market"] == "Fed"
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
def test_check_arbitrage_opportunities_success(self, mock_get):
"""Test successful arbitrage checking."""
# Mock successful response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"opportunities": [
{
"markets": ["Fed", "BTC"],
"profit_potential": 0.05,
"risk_level": "medium"
}
]
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
tool = NexusPredictionMarketTool()
result = tool.check_arbitrage_opportunities(["Fed", "BTC"])
assert result["success"] is True
assert result["markets"] == ["Fed", "BTC"]
assert "arbitrage_data" in result
assert result["source"] == "nexus-paid-api"
# Verify request was made correctly
mock_get.assert_called_once()
args, kwargs = mock_get.call_args
assert "arb/check" in args[0]
assert kwargs["params"]["markets"] == "Fed,BTC"
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
def test_check_arbitrage_payment_required(self, mock_get):
"""Test arbitrage checking when payment is required."""
# Mock payment required response
mock_response = Mock()
mock_response.status_code = 402
http_error = requests.exceptions.HTTPError("Payment Required")
http_error.response = mock_response
mock_get.side_effect = http_error
tool = NexusPredictionMarketTool()
result = tool.check_arbitrage_opportunities(["Fed", "BTC"])
assert result["success"] is False
assert "payment required" in result["error"].lower()
assert result["markets"] == ["Fed", "BTC"]
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
def test_get_agent_info_success(self, mock_get):
"""Test successful agent info retrieval."""
# Mock successful response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"name": "NEXUS",
"description": "Prediction market data API",
"version": "1.0"
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
tool = NexusPredictionMarketTool()
result = tool.get_agent_info()
assert result["success"] is True
assert "agent_info" in result
assert result["source"] == "nexus-agent-metadata"
# Verify request was made correctly
mock_get.assert_called_once()
args, kwargs = mock_get.call_args
assert ".well-known/agent.json" in args[0]
def test_run_method_get_market_data(self):
"""Test run method with get_market_data action."""
tool = NexusPredictionMarketTool()
with patch.object(tool, 'get_market_data') as mock_method:
mock_method.return_value = {"success": True, "test": "data"}
result = tool.run("get_market_data", market="Fed")
result_data = json.loads(result)
assert result_data["success"] is True
mock_method.assert_called_once_with("Fed")
def test_run_method_check_arbitrage(self):
"""Test run method with check_arbitrage action."""
tool = NexusPredictionMarketTool()
with patch.object(tool, 'check_arbitrage_opportunities') as mock_method:
mock_method.return_value = {"success": True, "test": "data"}
result = tool.run("check_arbitrage", markets=["Fed", "BTC"])
result_data = json.loads(result)
assert result_data["success"] is True
mock_method.assert_called_once_with(["Fed", "BTC"])
def test_run_method_get_agent_info(self):
"""Test run method with get_agent_info action."""
tool = NexusPredictionMarketTool()
with patch.object(tool, 'get_agent_info') as mock_method:
mock_method.return_value = {"success": True, "test": "data"}
result = tool.run("get_agent_info")
result_data = json.loads(result)
assert result_data["success"] is True
mock_method.assert_called_once()
def test_run_method_invalid_action(self):
"""Test run method with invalid action."""
tool = NexusPredictionMarketTool()
result = tool.run("invalid_action")
result_data = json.loads(result)
assert result_data["success"] is False
assert "unknown action" in result_data["error"].lower()
def test_run_method_missing_parameters(self):
"""Test run method with missing required parameters."""
tool = NexusPredictionMarketTool()
# Missing market parameter
result = tool.run("get_market_data")
result_data = json.loads(result)
assert result_data["success"] is False
assert "market parameter required" in result_data["error"].lower()
# Missing markets parameter
result = tool.run("check_arbitrage")
result_data = json.loads(result)
assert result_data["success"] is False
assert "markets parameter" in result_data["error"].lower()
class TestNexusPredictionMarketFunction:
"""Test cases for the function-based interface"""
def test_function_interface(self):
"""Test the function-based interface."""
with patch('praisonai_tools.tools.nexus_prediction_market_tool.NexusPredictionMarketTool') as mock_class:
mock_instance = Mock()
mock_instance.run.return_value = '{"success": true}'
mock_class.return_value = mock_instance
result = nexus_prediction_market_tool(
action="get_market_data",
market="Fed",
source_name="test-source"
)
assert result == '{"success": true}'
mock_class.assert_called_once_with(source_name="test-source")
mock_instance.run.assert_called_once_with(
action="get_market_data",
market="Fed",
markets=None
)
if __name__ == "__main__":
pytest.main([__file__])
+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