gcharanteja commited on
Commit Β·
631881d
1
Parent(s): 2c0e3f2
nigg
Browse files- .python-version +1 -1
- Dockerfile +1 -1
- README.md +4 -0
- jira_mcp_server.py +141 -0
- main.py +37 -2
- pyproject.toml +16 -1
- server.py +474 -0
- telegram_agent.py +479 -0
- uv.lock +0 -0
.python-version
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
3.
|
|
|
|
| 1 |
+
3.11
|
Dockerfile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
# Install dependencies
|
| 4 |
RUN apt-get update && apt-get install -y wget curl && \
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
|
| 3 |
# Install dependencies
|
| 4 |
RUN apt-get update && apt-get install -y wget curl && \
|
README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
---
|
| 2 |
title: Mann
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
|
|
|
| 4 |
---
|
| 5 |
|
| 6 |
# Mann - Terminal Loading Animation
|
|
|
|
| 1 |
---
|
| 2 |
title: Mann
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
# Mann - Terminal Loading Animation
|
jira_mcp_server.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Jira MCP Server
|
| 3 |
+
================
|
| 4 |
+
Exposes all Jira Sprint API endpoints as MCP tools.
|
| 5 |
+
The agent calls these tools natively β no JSON action routing needed.
|
| 6 |
+
|
| 7 |
+
Run this first:
|
| 8 |
+
python jira_mcp_server.py
|
| 9 |
+
|
| 10 |
+
Then in another terminal run the agent:
|
| 11 |
+
python jira_agent_mcp.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import requests
|
| 16 |
+
from mcp.server.fastmcp import FastMCP
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
JIRA_API_URL = os.environ.get("JIRA_API_URL", "http://0.0.0.0:8001")
|
| 22 |
+
|
| 23 |
+
mcp = FastMCP("Jira Sprint Manager")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def call(method: str, path: str, payload: dict = None) -> dict:
|
| 27 |
+
url = f"{JIRA_API_URL}{path}"
|
| 28 |
+
try:
|
| 29 |
+
if method == "GET":
|
| 30 |
+
r = requests.get(url, headers={"accept": "application/json"}, timeout=10)
|
| 31 |
+
else:
|
| 32 |
+
r = requests.request(
|
| 33 |
+
method, url,
|
| 34 |
+
headers={"Content-Type": "application/json", "accept": "application/json"},
|
| 35 |
+
json=payload or {},
|
| 36 |
+
timeout=10,
|
| 37 |
+
)
|
| 38 |
+
r.raise_for_status()
|
| 39 |
+
return r.json()
|
| 40 |
+
except requests.RequestException as e:
|
| 41 |
+
return {"success": False, "error": str(e)}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ MCP tools β one per Jira endpoint ββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
@mcp.tool()
|
| 47 |
+
def health_check() -> dict:
|
| 48 |
+
"""Check if the Jira API server is running and healthy."""
|
| 49 |
+
return call("GET", "/")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@mcp.tool()
|
| 53 |
+
def get_backlog() -> dict:
|
| 54 |
+
"""
|
| 55 |
+
Get all backlog issues β stories and tasks not assigned to any sprint.
|
| 56 |
+
Use this when the user asks about unassigned work, backlog items, or pending stories.
|
| 57 |
+
"""
|
| 58 |
+
return call("GET", "/api/backlog")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@mcp.tool()
|
| 62 |
+
def create_story(name: str, description: str) -> dict:
|
| 63 |
+
"""
|
| 64 |
+
Create a new story/task/issue in Jira.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
name: Short title of the story (e.g. 'Implement login API')
|
| 68 |
+
description: Detailed description of what needs to be done
|
| 69 |
+
"""
|
| 70 |
+
return call("POST", "/api/story", {"name": name, "description": description})
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@mcp.tool()
|
| 74 |
+
def get_active_sprint() -> dict:
|
| 75 |
+
"""
|
| 76 |
+
Get the current active sprint with all its issues and their statuses.
|
| 77 |
+
Use this when the user asks about sprint progress, current work, or what is in the sprint.
|
| 78 |
+
"""
|
| 79 |
+
return call("GET", "/api/sprint/active")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@mcp.tool()
|
| 83 |
+
def add_issues_to_sprint(sprint_id: int, issue_keys: list[str]) -> dict:
|
| 84 |
+
"""
|
| 85 |
+
Add one or more backlog issues into an active sprint.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
sprint_id: The numeric ID of the sprint (e.g. 9)
|
| 89 |
+
issue_keys: List of Jira issue keys to add (e.g. ['SCRUM-17', 'SCRUM-19'])
|
| 90 |
+
"""
|
| 91 |
+
return call("POST", f"/api/sprint/{sprint_id}/add-issues", {"issue_keys": issue_keys})
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@mcp.tool()
|
| 95 |
+
def transition_issue(issue_key: str, status_code: str, comment: str = "") -> dict:
|
| 96 |
+
"""
|
| 97 |
+
Update the status of a Jira issue. Optionally add a comment explaining the change.
|
| 98 |
+
|
| 99 |
+
Status codes:
|
| 100 |
+
"1" = To Do
|
| 101 |
+
"2" = In Progress
|
| 102 |
+
"3" = Testing
|
| 103 |
+
"4" = Done
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
issue_key: The Jira issue key (e.g. 'SCRUM-17')
|
| 107 |
+
status_code: One of "1", "2", "3", "4"
|
| 108 |
+
comment: Optional comment to add to the issue (e.g. 'All tests passed')
|
| 109 |
+
"""
|
| 110 |
+
payload = {"status": status_code}
|
| 111 |
+
if comment:
|
| 112 |
+
payload["comment"] = comment
|
| 113 |
+
return call("POST", f"/api/issue/{issue_key}/transition", payload)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@mcp.tool()
|
| 117 |
+
def sprint_rollover(new_sprint_name: str, add_backlog_to_new_sprint: bool = False) -> dict:
|
| 118 |
+
"""
|
| 119 |
+
Close the current active sprint and start a new one.
|
| 120 |
+
Unfinished issues are automatically carried forward to the new sprint.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
new_sprint_name: Name for the new sprint (e.g. 'Sprint 2')
|
| 124 |
+
add_backlog_to_new_sprint: If True, also pull all backlog items into the new sprint
|
| 125 |
+
"""
|
| 126 |
+
return call("POST", "/api/sprint/rollover", {
|
| 127 |
+
"new_sprint_name": new_sprint_name,
|
| 128 |
+
"add_backlog_to_new_sprint": add_backlog_to_new_sprint,
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ββ run βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
print("=" * 50)
|
| 135 |
+
print(" Jira MCP Server starting...")
|
| 136 |
+
print(f" Jira API : {JIRA_API_URL}")
|
| 137 |
+
print(" Tools : health_check, get_backlog, create_story,")
|
| 138 |
+
print(" get_active_sprint, add_issues_to_sprint,")
|
| 139 |
+
print(" transition_issue, sprint_rollover")
|
| 140 |
+
print("=" * 50 + "\n")
|
| 141 |
+
mcp.run(transport="stdio")
|
main.py
CHANGED
|
@@ -1,13 +1,48 @@
|
|
| 1 |
import time
|
|
|
|
|
|
|
| 2 |
from tqdm import tqdm
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
try:
|
| 7 |
for i in tqdm(range(1000000), desc="Loading NINININININININ", unit="%", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}"):
|
| 8 |
time.sleep(0.01)
|
| 9 |
except KeyboardInterrupt:
|
| 10 |
print("\nLoading interrupted!")
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if __name__ == "__main__":
|
| 13 |
main()
|
|
|
|
| 1 |
import time
|
| 2 |
+
import threading
|
| 3 |
+
import os
|
| 4 |
from tqdm import tqdm
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
import uvicorn
|
| 7 |
|
| 8 |
+
app = FastAPI()
|
| 9 |
+
|
| 10 |
+
def loading_animation():
|
| 11 |
+
"""Infinite loading animation running in background."""
|
| 12 |
try:
|
| 13 |
for i in tqdm(range(1000000), desc="Loading NINININININININ", unit="%", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}"):
|
| 14 |
time.sleep(0.01)
|
| 15 |
except KeyboardInterrupt:
|
| 16 |
print("\nLoading interrupted!")
|
| 17 |
|
| 18 |
+
@app.get("/health")
|
| 19 |
+
def health():
|
| 20 |
+
return {"status": "ok", "message": "Server is running"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@app.get("/healthz")
|
| 24 |
+
def healthz():
|
| 25 |
+
return {"status": "ok"}
|
| 26 |
+
|
| 27 |
+
@app.get("/")
|
| 28 |
+
def root():
|
| 29 |
+
return {
|
| 30 |
+
"message": "Mann API",
|
| 31 |
+
"endpoints": {
|
| 32 |
+
"/health": "Health check",
|
| 33 |
+
"/healthz": "Lightweight health check",
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def main():
|
| 38 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 39 |
+
|
| 40 |
+
# Start loading animation in background thread
|
| 41 |
+
loading_thread = threading.Thread(target=loading_animation, daemon=True)
|
| 42 |
+
loading_thread.start()
|
| 43 |
+
|
| 44 |
+
# Start FastAPI server
|
| 45 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
| 46 |
+
|
| 47 |
if __name__ == "__main__":
|
| 48 |
main()
|
pyproject.toml
CHANGED
|
@@ -3,7 +3,22 @@ name = "mann"
|
|
| 3 |
version = "0.1.0"
|
| 4 |
description = "Add your description here"
|
| 5 |
readme = "README.md"
|
| 6 |
-
requires-python = ">=3.
|
| 7 |
dependencies = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"tqdm>=4.67.3",
|
|
|
|
| 9 |
]
|
|
|
|
| 3 |
version = "0.1.0"
|
| 4 |
description = "Add your description here"
|
| 5 |
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
dependencies = [
|
| 8 |
+
"accelerate>=0.30.0",
|
| 9 |
+
"bitsandbytes>=0.43.0",
|
| 10 |
+
"fastapi>=0.115.0",
|
| 11 |
+
"huggingface-hub>=1.10.1",
|
| 12 |
+
"mcp>=1.27.0",
|
| 13 |
+
"pinecone>=8.1.2",
|
| 14 |
+
"python-dotenv>=1.1.1",
|
| 15 |
+
"pydantic>=2.0.0",
|
| 16 |
+
"pytelegrambotapi>=4.33.0",
|
| 17 |
+
"requests>=2.33.1",
|
| 18 |
+
"sentence-transformers>=5.4.0",
|
| 19 |
+
"torch>=2.3.0",
|
| 20 |
+
"transformers>=4.45.0",
|
| 21 |
+
"uvicorn>=0.30.0",
|
| 22 |
"tqdm>=4.67.3",
|
| 23 |
+
"llama-cpp-python>=0.3.20",
|
| 24 |
]
|
server.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Jira Sprint Management REST API
|
| 3 |
+
Built with FastAPI - allows other applications to interact with Jira programmatically
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from datetime import datetime, timedelta, timezone
|
| 10 |
+
import requests
|
| 11 |
+
from requests.auth import HTTPBasicAuth
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
# ================= CONFIG =================
|
| 19 |
+
BASE_URL = os.environ.get("JIRA_BASE_URL", "")
|
| 20 |
+
EMAIL = os.environ.get("JIRA_EMAIL", "")
|
| 21 |
+
API_TOKEN = os.environ.get("JIRA_API_TOKEN", "")
|
| 22 |
+
|
| 23 |
+
PROJECT_KEY = os.environ.get("JIRA_PROJECT_KEY", "SCRUM")
|
| 24 |
+
BOARD_ID = int(os.environ.get("JIRA_BOARD_ID", "1"))
|
| 25 |
+
|
| 26 |
+
if not BASE_URL:
|
| 27 |
+
raise ValueError("Missing required environment variable: JIRA_BASE_URL")
|
| 28 |
+
if not EMAIL:
|
| 29 |
+
raise ValueError("Missing required environment variable: JIRA_EMAIL")
|
| 30 |
+
if not API_TOKEN:
|
| 31 |
+
raise ValueError("Missing required environment variable: JIRA_API_TOKEN")
|
| 32 |
+
|
| 33 |
+
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
|
| 34 |
+
headers = {
|
| 35 |
+
"Accept": "application/json",
|
| 36 |
+
"Content-Type": "application/json"
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# ================= FASTAPI APP =================
|
| 40 |
+
app = FastAPI(
|
| 41 |
+
title="Jira Sprint Management API",
|
| 42 |
+
description="REST API for managing Jira sprints, stories, and issues",
|
| 43 |
+
version="1.0.0"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ================= REQUEST/RESPONSE MODELS =================
|
| 48 |
+
class StoryCreate(BaseModel):
|
| 49 |
+
name: str = Field(..., description="Story name/summary")
|
| 50 |
+
description: Optional[str] = Field("", description="Story description")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SprintAddIssues(BaseModel):
|
| 54 |
+
issue_keys: list[str] = Field(..., description="List of issue keys to add to sprint")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class IssueTransition(BaseModel):
|
| 58 |
+
status: str = Field(..., description="Status: '1'=To Do, '2'=In Progress, '3'=Testing, '4'=Done")
|
| 59 |
+
comment: Optional[str] = Field("", description="Reason/comment for the status change")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class SprintRollover(BaseModel):
|
| 63 |
+
new_sprint_name: str = Field(..., description="Name for the new sprint")
|
| 64 |
+
add_backlog_to_new_sprint: bool = Field(
|
| 65 |
+
False,
|
| 66 |
+
description="Whether to add remaining backlog items to the new sprint"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ================= HELPER FUNCTIONS =================
|
| 71 |
+
|
| 72 |
+
# Map simple status codes to Jira transition IDs
|
| 73 |
+
STATUS_MAP = {
|
| 74 |
+
"1": {"id": "11", "name": "To Do"},
|
| 75 |
+
"2": {"id": "21", "name": "In Progress"},
|
| 76 |
+
"3": {"id": "31", "name": "Testing"},
|
| 77 |
+
"4": {"id": "41", "name": "Done"}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
def get_transition_id(status_code):
|
| 81 |
+
"""Convert simple status code (1,2,3,4) to Jira transition ID"""
|
| 82 |
+
if status_code not in STATUS_MAP:
|
| 83 |
+
raise ValueError(f"Invalid status code: {status_code}. Use 1=To Do, 2=In Progress, 3=Testing, 4=Done")
|
| 84 |
+
return STATUS_MAP[status_code]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_next_saturday_friday():
|
| 88 |
+
today = datetime.now(timezone.utc)
|
| 89 |
+
days_until_sat = (5 - today.weekday()) % 7
|
| 90 |
+
if days_until_sat == 0:
|
| 91 |
+
days_until_sat = 7
|
| 92 |
+
start = today + timedelta(days=days_until_sat)
|
| 93 |
+
end = start + timedelta(days=6)
|
| 94 |
+
return (
|
| 95 |
+
start.strftime("%Y-%m-%dT%H:%M:%S.000+0000"),
|
| 96 |
+
end.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def parse_description(desc_data):
|
| 101 |
+
"""Parse Jira description JSON to plain text"""
|
| 102 |
+
if not desc_data or "content" not in desc_data:
|
| 103 |
+
return ""
|
| 104 |
+
text = ""
|
| 105 |
+
for block in desc_data.get("content", []):
|
| 106 |
+
if "content" in block:
|
| 107 |
+
for item in block["content"]:
|
| 108 |
+
if "text" in item:
|
| 109 |
+
text += item["text"]
|
| 110 |
+
return text
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ================= API ENDPOINTS =================
|
| 114 |
+
|
| 115 |
+
@app.get("/")
|
| 116 |
+
def root():
|
| 117 |
+
"""API health check"""
|
| 118 |
+
return {
|
| 119 |
+
"status": "ok",
|
| 120 |
+
"message": "Jira Sprint Management API is running",
|
| 121 |
+
"version": "1.0.0"
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.get("/api/backlog")
|
| 126 |
+
def get_backlog():
|
| 127 |
+
"""
|
| 128 |
+
Get all backlog issues (issues not in any sprint and not done)
|
| 129 |
+
Returns: List of issues with id, name, description, status
|
| 130 |
+
"""
|
| 131 |
+
try:
|
| 132 |
+
jql = f'project={PROJECT_KEY} AND sprint IS EMPTY AND statusCategory != Done'
|
| 133 |
+
url = f"{BASE_URL}/rest/api/3/search/jql"
|
| 134 |
+
params = {
|
| 135 |
+
"jql": jql,
|
| 136 |
+
"fields": "summary,status,description",
|
| 137 |
+
"maxResults": 100
|
| 138 |
+
}
|
| 139 |
+
res = requests.get(url, headers=headers, auth=auth, params=params)
|
| 140 |
+
res.raise_for_status()
|
| 141 |
+
issues = res.json()["issues"]
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"success": True,
|
| 145 |
+
"count": len(issues),
|
| 146 |
+
"issues": [
|
| 147 |
+
{
|
| 148 |
+
"id": issue["key"],
|
| 149 |
+
"name": issue["fields"]["summary"],
|
| 150 |
+
"description": parse_description(issue["fields"].get("description")),
|
| 151 |
+
"status": issue["fields"]["status"]["name"]
|
| 152 |
+
}
|
| 153 |
+
for issue in issues
|
| 154 |
+
]
|
| 155 |
+
}
|
| 156 |
+
except Exception as e:
|
| 157 |
+
raise HTTPException(status_code=500, detail=f"Failed to fetch backlog: {str(e)}")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@app.post("/api/story")
|
| 161 |
+
def create_story(story: StoryCreate):
|
| 162 |
+
"""
|
| 163 |
+
Create a new story in Jira
|
| 164 |
+
Returns: Created issue details with id and key
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
url = f"{BASE_URL}/rest/api/3/issue"
|
| 168 |
+
payload = {
|
| 169 |
+
"fields": {
|
| 170 |
+
"project": {"key": PROJECT_KEY},
|
| 171 |
+
"summary": story.name,
|
| 172 |
+
"issuetype": {"name": "Story"},
|
| 173 |
+
"description": {
|
| 174 |
+
"type": "doc",
|
| 175 |
+
"version": 1,
|
| 176 |
+
"content": [
|
| 177 |
+
{
|
| 178 |
+
"type": "paragraph",
|
| 179 |
+
"content": [
|
| 180 |
+
{
|
| 181 |
+
"type": "text",
|
| 182 |
+
"text": story.description if story.description else "No description provided"
|
| 183 |
+
}
|
| 184 |
+
]
|
| 185 |
+
}
|
| 186 |
+
]
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
res = requests.post(url, headers=headers, auth=auth, data=json.dumps(payload))
|
| 191 |
+
res.raise_for_status()
|
| 192 |
+
data = res.json()
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"success": True,
|
| 196 |
+
"message": "Story created successfully",
|
| 197 |
+
"issue": {
|
| 198 |
+
"id": data["id"],
|
| 199 |
+
"key": data["key"],
|
| 200 |
+
"name": story.name,
|
| 201 |
+
"description": story.description
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
except Exception as e:
|
| 205 |
+
raise HTTPException(status_code=500, detail=f"Failed to create story: {str(e)}")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@app.get("/api/sprint/active")
|
| 209 |
+
def get_active_sprint():
|
| 210 |
+
"""
|
| 211 |
+
Get the current active sprint and its issues
|
| 212 |
+
Returns: Sprint details with all issues and their statuses
|
| 213 |
+
"""
|
| 214 |
+
try:
|
| 215 |
+
url = f"{BASE_URL}/rest/agile/1.0/board/{BOARD_ID}/sprint?state=active"
|
| 216 |
+
res = requests.get(url, headers=headers, auth=auth)
|
| 217 |
+
res.raise_for_status()
|
| 218 |
+
sprints = res.json()["values"]
|
| 219 |
+
|
| 220 |
+
if not sprints:
|
| 221 |
+
return {
|
| 222 |
+
"success": True,
|
| 223 |
+
"active_sprint": None,
|
| 224 |
+
"message": "No active sprint found"
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
sprint = sprints[0]
|
| 228 |
+
sprint_id = sprint["id"]
|
| 229 |
+
|
| 230 |
+
# Get sprint issues
|
| 231 |
+
issues_url = f"{BASE_URL}/rest/agile/1.0/sprint/{sprint_id}/issue"
|
| 232 |
+
issues_res = requests.get(issues_url, headers=headers, auth=auth)
|
| 233 |
+
issues_res.raise_for_status()
|
| 234 |
+
issues = issues_res.json()["issues"]
|
| 235 |
+
|
| 236 |
+
return {
|
| 237 |
+
"success": True,
|
| 238 |
+
"active_sprint": {
|
| 239 |
+
"id": sprint["id"],
|
| 240 |
+
"name": sprint["name"],
|
| 241 |
+
"state": sprint["state"],
|
| 242 |
+
"start_date": sprint["startDate"],
|
| 243 |
+
"end_date": sprint["endDate"],
|
| 244 |
+
"issues": [
|
| 245 |
+
{
|
| 246 |
+
"id": issue["key"],
|
| 247 |
+
"name": issue["fields"]["summary"],
|
| 248 |
+
"status": issue["fields"]["status"]["name"],
|
| 249 |
+
"status_category": issue["fields"]["status"]["statusCategory"]["name"]
|
| 250 |
+
}
|
| 251 |
+
for issue in issues
|
| 252 |
+
],
|
| 253 |
+
"issue_count": len(issues)
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
except Exception as e:
|
| 257 |
+
raise HTTPException(status_code=500, detail=f"Failed to fetch active sprint: {str(e)}")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@app.post("/api/sprint/{sprint_id}/add-issues")
|
| 261 |
+
def add_issues_to_sprint(sprint_id: int, body: SprintAddIssues):
|
| 262 |
+
"""
|
| 263 |
+
Add issues to a sprint
|
| 264 |
+
Returns: Success message with count of added issues
|
| 265 |
+
"""
|
| 266 |
+
try:
|
| 267 |
+
url = f"{BASE_URL}/rest/agile/1.0/sprint/{sprint_id}/issue"
|
| 268 |
+
payload = {"issues": body.issue_keys}
|
| 269 |
+
res = requests.post(url, headers=headers, auth=auth, data=json.dumps(payload))
|
| 270 |
+
res.raise_for_status()
|
| 271 |
+
|
| 272 |
+
return {
|
| 273 |
+
"success": True,
|
| 274 |
+
"message": f"Added {len(body.issue_keys)} issues to sprint",
|
| 275 |
+
"added_issues": body.issue_keys,
|
| 276 |
+
"sprint_id": sprint_id
|
| 277 |
+
}
|
| 278 |
+
except Exception as e:
|
| 279 |
+
raise HTTPException(status_code=500, detail=f"Failed to add issues to sprint: {str(e)}")
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@app.post("/api/issue/{issue_key}/transition")
|
| 283 |
+
def transition_issue(issue_key: str, body: IssueTransition):
|
| 284 |
+
"""
|
| 285 |
+
Transition an issue to a new status with an optional comment/reason
|
| 286 |
+
Status codes: 1=To Do, 2=In Progress, 3=Testing, 4=Done
|
| 287 |
+
Returns: Success message
|
| 288 |
+
"""
|
| 289 |
+
try:
|
| 290 |
+
# Get the Jira transition ID from simple status code
|
| 291 |
+
transition = get_transition_id(body.status)
|
| 292 |
+
transition_id = transition["id"]
|
| 293 |
+
status_name = transition["name"]
|
| 294 |
+
|
| 295 |
+
# Step 1: Perform the transition
|
| 296 |
+
url = f"{BASE_URL}/rest/api/3/issue/{issue_key}/transitions"
|
| 297 |
+
payload = {"transition": {"id": transition_id}}
|
| 298 |
+
|
| 299 |
+
res = requests.post(url, headers=headers, auth=auth, data=json.dumps(payload))
|
| 300 |
+
|
| 301 |
+
if res.status_code != 204:
|
| 302 |
+
raise HTTPException(
|
| 303 |
+
status_code=400,
|
| 304 |
+
detail=f"Transition failed: {res.text}"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
# Step 2: Add comment separately (if provided)
|
| 308 |
+
comment_added = None
|
| 309 |
+
if body.comment:
|
| 310 |
+
comment_url = f"{BASE_URL}/rest/api/3/issue/{issue_key}/comment"
|
| 311 |
+
comment_payload = {
|
| 312 |
+
"body": {
|
| 313 |
+
"type": "doc",
|
| 314 |
+
"version": 1,
|
| 315 |
+
"content": [
|
| 316 |
+
{
|
| 317 |
+
"type": "paragraph",
|
| 318 |
+
"content": [
|
| 319 |
+
{"type": "text", "text": body.comment}
|
| 320 |
+
]
|
| 321 |
+
}
|
| 322 |
+
]
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
comment_res = requests.post(comment_url, headers=headers, auth=auth, data=json.dumps(comment_payload))
|
| 326 |
+
if comment_res.status_code == 201:
|
| 327 |
+
comment_added = body.comment
|
| 328 |
+
else:
|
| 329 |
+
# Log but don't fail - transition already succeeded
|
| 330 |
+
print(f"Warning: Comment not added for {issue_key}: {comment_res.text}")
|
| 331 |
+
|
| 332 |
+
return {
|
| 333 |
+
"success": True,
|
| 334 |
+
"message": f"Issue {issue_key} moved to {status_name}",
|
| 335 |
+
"issue_key": issue_key,
|
| 336 |
+
"status": status_name,
|
| 337 |
+
"status_code": body.status,
|
| 338 |
+
"comment_added": comment_added
|
| 339 |
+
}
|
| 340 |
+
except ValueError as e:
|
| 341 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 342 |
+
except HTTPException:
|
| 343 |
+
raise
|
| 344 |
+
except Exception as e:
|
| 345 |
+
raise HTTPException(status_code=500, detail=f"Failed to transition issue: {str(e)}")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@app.post("/api/sprint/rollover")
|
| 349 |
+
def sprint_rollover(body: SprintRollover):
|
| 350 |
+
"""
|
| 351 |
+
End current sprint and start a new one
|
| 352 |
+
- Closes current sprint
|
| 353 |
+
- Carries forward unfinished issues
|
| 354 |
+
- Creates and starts new sprint
|
| 355 |
+
- Optionally adds backlog items to new sprint
|
| 356 |
+
|
| 357 |
+
Returns: Details of the new sprint and carried forward issues
|
| 358 |
+
"""
|
| 359 |
+
try:
|
| 360 |
+
# Get active sprint
|
| 361 |
+
url = f"{BASE_URL}/rest/agile/1.0/board/{BOARD_ID}/sprint?state=active"
|
| 362 |
+
res = requests.get(url, headers=headers, auth=auth)
|
| 363 |
+
res.raise_for_status()
|
| 364 |
+
sprints = res.json()["values"]
|
| 365 |
+
|
| 366 |
+
if not sprints:
|
| 367 |
+
raise HTTPException(status_code=400, detail="No active sprint to close")
|
| 368 |
+
|
| 369 |
+
active_sprint = sprints[0]
|
| 370 |
+
|
| 371 |
+
# Get sprint issues to find unfinished ones
|
| 372 |
+
issues_url = f"{BASE_URL}/rest/agile/1.0/sprint/{active_sprint['id']}/issue"
|
| 373 |
+
issues_res = requests.get(issues_url, headers=headers, auth=auth)
|
| 374 |
+
issues_res.raise_for_status()
|
| 375 |
+
sprint_issues = issues_res.json()["issues"]
|
| 376 |
+
|
| 377 |
+
# Find unfinished issues
|
| 378 |
+
unfinished = [
|
| 379 |
+
issue["key"]
|
| 380 |
+
for issue in sprint_issues
|
| 381 |
+
if issue["fields"]["status"]["statusCategory"]["name"] != "Done"
|
| 382 |
+
]
|
| 383 |
+
|
| 384 |
+
# Close current sprint
|
| 385 |
+
close_url = f"{BASE_URL}/rest/agile/1.0/sprint/{active_sprint['id']}"
|
| 386 |
+
close_payload = {
|
| 387 |
+
"state": "closed",
|
| 388 |
+
"name": active_sprint["name"],
|
| 389 |
+
"startDate": active_sprint["startDate"],
|
| 390 |
+
"endDate": active_sprint["endDate"]
|
| 391 |
+
}
|
| 392 |
+
close_res = requests.put(close_url, headers=headers, auth=auth, data=json.dumps(close_payload))
|
| 393 |
+
if close_res.status_code != 200:
|
| 394 |
+
raise HTTPException(status_code=400, detail=f"Failed to close sprint: {close_res.text}")
|
| 395 |
+
|
| 396 |
+
# Create new sprint
|
| 397 |
+
start, end = get_next_saturday_friday()
|
| 398 |
+
create_url = f"{BASE_URL}/rest/agile/1.0/sprint"
|
| 399 |
+
create_payload = {
|
| 400 |
+
"name": body.new_sprint_name,
|
| 401 |
+
"originBoardId": BOARD_ID,
|
| 402 |
+
"startDate": start,
|
| 403 |
+
"endDate": end
|
| 404 |
+
}
|
| 405 |
+
create_res = requests.post(create_url, headers=headers, auth=auth, data=json.dumps(create_payload))
|
| 406 |
+
create_res.raise_for_status()
|
| 407 |
+
new_sprint = create_res.json()
|
| 408 |
+
|
| 409 |
+
# Start new sprint
|
| 410 |
+
start_url = f"{BASE_URL}/rest/agile/1.0/sprint/{new_sprint['id']}"
|
| 411 |
+
start_payload = {
|
| 412 |
+
"state": "active",
|
| 413 |
+
"startDate": start,
|
| 414 |
+
"endDate": end,
|
| 415 |
+
"name": body.new_sprint_name
|
| 416 |
+
}
|
| 417 |
+
start_res = requests.put(start_url, headers=headers, auth=auth, data=json.dumps(start_payload))
|
| 418 |
+
start_res.raise_for_status()
|
| 419 |
+
|
| 420 |
+
# Add carry-forward issues
|
| 421 |
+
if unfinished:
|
| 422 |
+
add_url = f"{BASE_URL}/rest/agile/1.0/sprint/{new_sprint['id']}/issue"
|
| 423 |
+
add_payload = {"issues": unfinished}
|
| 424 |
+
add_res = requests.post(add_url, headers=headers, auth=auth, data=json.dumps(add_payload))
|
| 425 |
+
add_res.raise_for_status()
|
| 426 |
+
|
| 427 |
+
# Optionally add backlog
|
| 428 |
+
added_backlog = []
|
| 429 |
+
if body.add_backlog_to_new_sprint:
|
| 430 |
+
backlog_jql = f'project={PROJECT_KEY} AND sprint IS EMPTY AND statusCategory != Done'
|
| 431 |
+
backlog_url = f"{BASE_URL}/rest/api/3/search/jql"
|
| 432 |
+
backlog_params = {"jql": backlog_jql, "maxResults": 100}
|
| 433 |
+
backlog_res = requests.get(backlog_url, headers=headers, auth=auth, params=backlog_params)
|
| 434 |
+
backlog_res.raise_for_status()
|
| 435 |
+
backlog_issues = backlog_res.json()["issues"]
|
| 436 |
+
backlog_keys = [issue["key"] for issue in backlog_issues]
|
| 437 |
+
|
| 438 |
+
if backlog_keys:
|
| 439 |
+
add_backlog_url = f"{BASE_URL}/rest/agile/1.0/sprint/{new_sprint['id']}/issue"
|
| 440 |
+
add_backlog_payload = {"issues": backlog_keys}
|
| 441 |
+
add_backlog_res = requests.post(add_backlog_url, headers=headers, auth=auth, data=json.dumps(add_backlog_payload))
|
| 442 |
+
add_backlog_res.raise_for_status()
|
| 443 |
+
added_backlog = backlog_keys
|
| 444 |
+
|
| 445 |
+
return {
|
| 446 |
+
"success": True,
|
| 447 |
+
"message": "Sprint rolled over successfully",
|
| 448 |
+
"old_sprint": {
|
| 449 |
+
"id": active_sprint["id"],
|
| 450 |
+
"name": active_sprint["name"],
|
| 451 |
+
"status": "closed"
|
| 452 |
+
},
|
| 453 |
+
"new_sprint": {
|
| 454 |
+
"id": new_sprint["id"],
|
| 455 |
+
"name": body.new_sprint_name,
|
| 456 |
+
"start_date": start,
|
| 457 |
+
"end_date": end,
|
| 458 |
+
"status": "active"
|
| 459 |
+
},
|
| 460 |
+
"carried_forward": unfinished,
|
| 461 |
+
"carried_forward_count": len(unfinished),
|
| 462 |
+
"added_from_backlog": added_backlog,
|
| 463 |
+
"backlog_count": len(added_backlog)
|
| 464 |
+
}
|
| 465 |
+
except HTTPException:
|
| 466 |
+
raise
|
| 467 |
+
except Exception as e:
|
| 468 |
+
raise HTTPException(status_code=500, detail=f"Failed to rollover sprint: {str(e)}")
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
# ================= RUN =================
|
| 472 |
+
if __name__ == "__main__":
|
| 473 |
+
import uvicorn
|
| 474 |
+
uvicorn.run(app, host="0.0.0.0", port=8001)
|
telegram_agent.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Jira Sprint Agent β Telegram + MCP Edition
|
| 3 |
+
============================================
|
| 4 |
+
Stack:
|
| 5 |
+
Phi-4-Mini (llama-cpp) β local LLM, manual tool calling (JSON)
|
| 6 |
+
MCP (stdio) β connects to jira_mcp_server.py
|
| 7 |
+
sentence-transformers β local embeddings (BAAI/bge-base-en-v1.5)
|
| 8 |
+
Pinecone β stores every turn + tool call + tool result
|
| 9 |
+
Telegram (pyTelegramBotAPI) β chat interface
|
| 10 |
+
|
| 11 |
+
Run order:
|
| 12 |
+
Terminal 1: python jira_mcp_server.py (keep running)
|
| 13 |
+
Terminal 2: python telegram_agent.py (Telegram bot)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import json
|
| 19 |
+
import uuid
|
| 20 |
+
import time
|
| 21 |
+
import asyncio
|
| 22 |
+
import datetime
|
| 23 |
+
import threading
|
| 24 |
+
from llama_cpp import Llama
|
| 25 |
+
from sentence_transformers import SentenceTransformer
|
| 26 |
+
from pinecone import Pinecone, ServerlessSpec
|
| 27 |
+
from dotenv import load_dotenv
|
| 28 |
+
from mcp import ClientSession, StdioServerParameters
|
| 29 |
+
from mcp.client.stdio import stdio_client
|
| 30 |
+
import telebot
|
| 31 |
+
|
| 32 |
+
load_dotenv()
|
| 33 |
+
|
| 34 |
+
# ββ config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN")
|
| 36 |
+
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY")
|
| 37 |
+
PINECONE_INDEX = os.environ.get("PINECONE_INDEX", "jira-agent-memory")
|
| 38 |
+
|
| 39 |
+
MCP_SERVER_SCRIPT = os.path.join(os.path.dirname(__file__), "jira_mcp_server.py")
|
| 40 |
+
|
| 41 |
+
EMBED_DIM = 768
|
| 42 |
+
|
| 43 |
+
MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "microsoft_Phi-4-mini-instruct-Q4_K_M.gguf")
|
| 44 |
+
|
| 45 |
+
if not TELEGRAM_TOKEN:
|
| 46 |
+
raise ValueError("Missing required environment variable: TELEGRAM_TOKEN")
|
| 47 |
+
if not PINECONE_API_KEY:
|
| 48 |
+
raise ValueError("Missing required environment variable: PINECONE_API_KEY")
|
| 49 |
+
|
| 50 |
+
# ββ Telegram bot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
bot = telebot.TeleBot(TELEGRAM_TOKEN, parse_mode=None)
|
| 52 |
+
|
| 53 |
+
# Per-user conversation state: {chat_id: {"session": ClientSession, "openai_tools": [...], "messages": [...]}}
|
| 54 |
+
user_sessions = {}
|
| 55 |
+
loop_ref = None # will hold the asyncio event loop
|
| 56 |
+
|
| 57 |
+
# ββ local embedder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
print(" Loading embedding model...")
|
| 59 |
+
_embedder = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cpu")
|
| 60 |
+
print(" Embedding model ready.\n")
|
| 61 |
+
|
| 62 |
+
def embed(text: str) -> list[float]:
|
| 63 |
+
vec = _embedder.encode(text[:8000], normalize_embeddings=True)
|
| 64 |
+
return vec.tolist()
|
| 65 |
+
|
| 66 |
+
# ββ local model loader βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
if not os.path.exists(MODEL_PATH):
|
| 68 |
+
print(f" Model not found at {MODEL_PATH}")
|
| 69 |
+
print(" Downloading from HuggingFace (bartowski/Phi-4-mini-instruct-GGUF)...\n")
|
| 70 |
+
print(" This is ~2.2 GB β may take a few minutes depending on your connection.\n")
|
| 71 |
+
models_dir = os.path.dirname(MODEL_PATH)
|
| 72 |
+
os.makedirs(models_dir, exist_ok=True)
|
| 73 |
+
from huggingface_hub import hf_hub_download
|
| 74 |
+
downloaded_path = hf_hub_download(
|
| 75 |
+
repo_id="bartowski/Phi-4-mini-instruct-GGUF",
|
| 76 |
+
filename="Phi-4-mini-instruct-Q4_K_M.gguf",
|
| 77 |
+
local_dir=models_dir,
|
| 78 |
+
local_dir_use_symlinks=False,
|
| 79 |
+
)
|
| 80 |
+
# Move to expected path if needed
|
| 81 |
+
if downloaded_path != MODEL_PATH and not os.path.exists(MODEL_PATH):
|
| 82 |
+
import shutil
|
| 83 |
+
shutil.move(downloaded_path, MODEL_PATH)
|
| 84 |
+
print("\n Download complete.\n")
|
| 85 |
+
|
| 86 |
+
print(" Loading local model...")
|
| 87 |
+
print(f" Model: {MODEL_PATH}")
|
| 88 |
+
print(" This may take 10-30 seconds on first run...\n")
|
| 89 |
+
|
| 90 |
+
llm = Llama(
|
| 91 |
+
model_path=MODEL_PATH,
|
| 92 |
+
n_ctx=8192,
|
| 93 |
+
n_threads=4,
|
| 94 |
+
n_batch=512,
|
| 95 |
+
n_gpu_layers=0,
|
| 96 |
+
verbose=False,
|
| 97 |
+
temperature=0.2,
|
| 98 |
+
)
|
| 99 |
+
print(" Model loaded successfully.\n")
|
| 100 |
+
|
| 101 |
+
# ββ pinecone ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
pc = Pinecone(api_key=PINECONE_API_KEY)
|
| 103 |
+
existing = [i.name for i in pc.list_indexes()]
|
| 104 |
+
if PINECONE_INDEX not in existing:
|
| 105 |
+
print(f" Creating Pinecone index '{PINECONE_INDEX}' (dim={EMBED_DIM})...")
|
| 106 |
+
pc.create_index(
|
| 107 |
+
name=PINECONE_INDEX,
|
| 108 |
+
dimension=EMBED_DIM,
|
| 109 |
+
metric="cosine",
|
| 110 |
+
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
|
| 111 |
+
)
|
| 112 |
+
print(" Index created.\n")
|
| 113 |
+
|
| 114 |
+
pine_index = pc.Index(PINECONE_INDEX)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def store(user_input: str, agent_reply: str, tool_name: str,
|
| 118 |
+
tool_args: dict, tool_result: dict):
|
| 119 |
+
doc = (
|
| 120 |
+
f"User: {user_input}\n"
|
| 121 |
+
f"Agent: {agent_reply}\n"
|
| 122 |
+
f"Tool called: {tool_name}\n"
|
| 123 |
+
f"Tool args: {json.dumps(tool_args)}\n"
|
| 124 |
+
f"Tool result: {json.dumps(tool_result)}"
|
| 125 |
+
)
|
| 126 |
+
pine_index.upsert(vectors=[{
|
| 127 |
+
"id": str(uuid.uuid4()),
|
| 128 |
+
"values": embed(doc),
|
| 129 |
+
"metadata": {
|
| 130 |
+
"doc": doc,
|
| 131 |
+
"user_input": user_input,
|
| 132 |
+
"agent_reply": agent_reply,
|
| 133 |
+
"tool_name": tool_name,
|
| 134 |
+
"tool_args": json.dumps(tool_args),
|
| 135 |
+
"tool_result": json.dumps(tool_result),
|
| 136 |
+
"timestamp": datetime.datetime.utcnow().isoformat(),
|
| 137 |
+
},
|
| 138 |
+
}])
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def recall(user_input: str, top_k: int = 2) -> str:
|
| 142 |
+
stats = pine_index.describe_index_stats()
|
| 143 |
+
total = stats.get("total_vector_count", 0)
|
| 144 |
+
if total == 0:
|
| 145 |
+
return ""
|
| 146 |
+
results = pine_index.query(
|
| 147 |
+
vector=embed(user_input),
|
| 148 |
+
top_k=min(top_k, total),
|
| 149 |
+
include_metadata=True,
|
| 150 |
+
)
|
| 151 |
+
matches = results.get("matches", [])
|
| 152 |
+
if not matches:
|
| 153 |
+
return ""
|
| 154 |
+
chunks = [
|
| 155 |
+
f"[past interaction]\n{m['metadata'].get('user_input', '')} β {m['metadata'].get('tool_name', 'no tool')}"
|
| 156 |
+
for m in matches if m.get('score', 0) > 0.5
|
| 157 |
+
]
|
| 158 |
+
if not chunks:
|
| 159 |
+
return ""
|
| 160 |
+
return "\nRECENT SIMILAR REQUESTS (for context only, do NOT reuse old data):\n" + "\n---\n".join(chunks) + "\n"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def mcp_tools_to_openai_schema(mcp_tools) -> list[dict]:
|
| 164 |
+
tools = []
|
| 165 |
+
for t in mcp_tools:
|
| 166 |
+
tools.append({
|
| 167 |
+
"type": "function",
|
| 168 |
+
"function": {
|
| 169 |
+
"name": t.name,
|
| 170 |
+
"description": t.description or "",
|
| 171 |
+
"parameters": t.inputSchema or {"type": "object", "properties": {}},
|
| 172 |
+
},
|
| 173 |
+
})
|
| 174 |
+
return tools
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ββ agent turn (returns final reply string) βββββββββββββββββββββββββββββββββββ
|
| 178 |
+
async def agent_turn_async(session, openai_tools, user_input: str, chat_id: int) -> str:
|
| 179 |
+
"""Run one full agent turn. Returns the final reply for the user."""
|
| 180 |
+
|
| 181 |
+
memory_block = recall(user_input)
|
| 182 |
+
|
| 183 |
+
tool_descriptions = ""
|
| 184 |
+
for t in openai_tools:
|
| 185 |
+
params = t["function"].get("parameters", {})
|
| 186 |
+
req = params.get("required", [])
|
| 187 |
+
props = params.get("properties", {})
|
| 188 |
+
param_str = ", ".join([f"{k}" + (" (required)" if k in req else "") for k in props.keys()]) if props else "none"
|
| 189 |
+
tool_descriptions += f"- {t['function']['name']}: {t['function']['description']} | params: {param_str}\n"
|
| 190 |
+
|
| 191 |
+
tool_descriptions += """
|
| 192 |
+
IMPORTANT: For add_issues_to_sprint, sprint_id must be a NUMBER (integer), NOT "current".
|
| 193 |
+
If you don't know the sprint_id, first call get_active_sprint to find the sprint ID, then use that number.
|
| 194 |
+
"""
|
| 195 |
+
|
| 196 |
+
system_prompt = f"""You are an intelligent Jira Sprint Management AI Agent.
|
| 197 |
+
|
| 198 |
+
You have these tools available to you. To call a tool, respond with ONLY a JSON object in this exact format:
|
| 199 |
+
{{"tool": "<tool_name>", "arguments": {{"<param>": "<value>"}}}}
|
| 200 |
+
|
| 201 |
+
Available tools:
|
| 202 |
+
{tool_descriptions}
|
| 203 |
+
|
| 204 |
+
RULES:
|
| 205 |
+
1. For ANY query about Jira data, you MUST call one of the tools above by responding with JSON.
|
| 206 |
+
2. Do NOT make up Jira data. Only report what the tool returns.
|
| 207 |
+
3. If the user asks for something that doesn't need a tool (e.g. greeting), just respond normally.
|
| 208 |
+
4. After the tool result comes back, summarize it concisely for the user.
|
| 209 |
+
5. Be concise.
|
| 210 |
+
|
| 211 |
+
{memory_block}"""
|
| 212 |
+
|
| 213 |
+
final_reply = ""
|
| 214 |
+
tool_calls_parsed = []
|
| 215 |
+
all_results = []
|
| 216 |
+
|
| 217 |
+
# Build messages for this turn (do NOT pollute with old internal tool messages)
|
| 218 |
+
messages = [
|
| 219 |
+
{"role": "system", "content": system_prompt},
|
| 220 |
+
{"role": "user", "content": user_input},
|
| 221 |
+
]
|
| 222 |
+
# Add clean conversation history (only user/assistant exchanges, max 6 turns)
|
| 223 |
+
user_sessions.setdefault(chat_id, {"messages": []})
|
| 224 |
+
clean_history = []
|
| 225 |
+
for m in user_sessions[chat_id]["messages"]:
|
| 226 |
+
if m["role"] in ("user", "assistant"):
|
| 227 |
+
clean_history.append(m)
|
| 228 |
+
messages.extend(clean_history[-6:])
|
| 229 |
+
|
| 230 |
+
tool_name = "none"
|
| 231 |
+
tool_args = {}
|
| 232 |
+
tool_result = {}
|
| 233 |
+
final_reply = ""
|
| 234 |
+
tool_calls_parsed = []
|
| 235 |
+
all_results = []
|
| 236 |
+
|
| 237 |
+
max_tool_rounds = 5
|
| 238 |
+
for _ in range(max_tool_rounds):
|
| 239 |
+
try:
|
| 240 |
+
response = llm.create_chat_completion(
|
| 241 |
+
messages=messages,
|
| 242 |
+
max_tokens=1024,
|
| 243 |
+
temperature=0.2,
|
| 244 |
+
top_p=0.9,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
choice = response['choices'][0]
|
| 248 |
+
message = choice['message']
|
| 249 |
+
response_text = message.get("content", "")
|
| 250 |
+
|
| 251 |
+
# Strip <think> blocks
|
| 252 |
+
if "<think>" in response_text and "</think>" in response_text:
|
| 253 |
+
response_text = response_text[response_text.rfind("</think>") + len("</think>"):].strip()
|
| 254 |
+
|
| 255 |
+
# Parse ONE or MORE JSON tool calls
|
| 256 |
+
tool_calls_parsed = []
|
| 257 |
+
remaining = response_text
|
| 258 |
+
while True:
|
| 259 |
+
json_start = remaining.find("{")
|
| 260 |
+
if json_start < 0:
|
| 261 |
+
break
|
| 262 |
+
remaining = remaining[json_start:]
|
| 263 |
+
depth = 0
|
| 264 |
+
json_end = -1
|
| 265 |
+
for i, ch in enumerate(remaining):
|
| 266 |
+
if ch == "{":
|
| 267 |
+
depth += 1
|
| 268 |
+
elif ch == "}":
|
| 269 |
+
depth -= 1
|
| 270 |
+
if depth == 0:
|
| 271 |
+
json_end = i + 1
|
| 272 |
+
break
|
| 273 |
+
if json_end < 0:
|
| 274 |
+
break
|
| 275 |
+
try:
|
| 276 |
+
parsed = json.loads(remaining[:json_end])
|
| 277 |
+
if "tool" in parsed:
|
| 278 |
+
tool_calls_parsed.append(parsed)
|
| 279 |
+
except json.JSONDecodeError:
|
| 280 |
+
pass
|
| 281 |
+
remaining = remaining[json_end:]
|
| 282 |
+
|
| 283 |
+
# Model wants to call tools
|
| 284 |
+
if tool_calls_parsed:
|
| 285 |
+
all_results = []
|
| 286 |
+
for tc in tool_calls_parsed:
|
| 287 |
+
tool_name = tc.get("tool", "unknown")
|
| 288 |
+
tool_args = tc.get("arguments", {})
|
| 289 |
+
|
| 290 |
+
print(f"\n β MCP tool: {tool_name}({json.dumps(tool_args)})")
|
| 291 |
+
|
| 292 |
+
result = await session.call_tool(tool_name, arguments=tool_args)
|
| 293 |
+
|
| 294 |
+
if result.content and len(result.content) > 0:
|
| 295 |
+
raw_result = result.content[0].text if hasattr(result.content[0], "text") else str(result.content[0])
|
| 296 |
+
try:
|
| 297 |
+
tool_result = json.loads(raw_result)
|
| 298 |
+
except json.JSONDecodeError:
|
| 299 |
+
tool_result = {"raw": raw_result}
|
| 300 |
+
else:
|
| 301 |
+
tool_result = {}
|
| 302 |
+
|
| 303 |
+
print(f" result: {json.dumps(tool_result, indent=2)}\n")
|
| 304 |
+
all_results.append((tool_name, tool_result))
|
| 305 |
+
|
| 306 |
+
messages.append({
|
| 307 |
+
"role": "assistant",
|
| 308 |
+
"content": response_text,
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
results_text_parts = []
|
| 312 |
+
for tn, tr in all_results:
|
| 313 |
+
results_text_parts.append(f"Tool {tn} returned: {json.dumps(tr)}")
|
| 314 |
+
|
| 315 |
+
has_sprint_id_error = any(
|
| 316 |
+
"sprint_id" in json.dumps(tr).lower() and ("integer" in json.dumps(tr).lower() or "int_parsing" in json.dumps(tr).lower())
|
| 317 |
+
for _, tr in all_results
|
| 318 |
+
)
|
| 319 |
+
if has_sprint_id_error:
|
| 320 |
+
messages.append({
|
| 321 |
+
"role": "user",
|
| 322 |
+
"content": f"{' | '.join(results_text_parts)}\n\nThe sprint_id must be a number. First call get_active_sprint to find the current sprint ID, then use that number.",
|
| 323 |
+
})
|
| 324 |
+
else:
|
| 325 |
+
messages.append({
|
| 326 |
+
"role": "user",
|
| 327 |
+
"content": f"{' | '.join(results_text_parts)}\n\nNow summarize the results for the user concisely.",
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
# Model gave final text answer
|
| 331 |
+
else:
|
| 332 |
+
final_reply = response_text
|
| 333 |
+
print(f"\n Agent: {final_reply}\n")
|
| 334 |
+
break
|
| 335 |
+
|
| 336 |
+
except Exception as e:
|
| 337 |
+
err = str(e).lower()
|
| 338 |
+
if "out of memory" in err:
|
| 339 |
+
print(" Memory error...\n")
|
| 340 |
+
return "Sorry, I ran into a memory error. Please try again."
|
| 341 |
+
print(f" Model Error: {str(e)[:300]}\n")
|
| 342 |
+
return f"Sorry, I ran into an error: {str(e)[:200]}"
|
| 343 |
+
|
| 344 |
+
# Store in Pinecone
|
| 345 |
+
if all_results:
|
| 346 |
+
last_tool_name, last_tool_res = all_results[-1]
|
| 347 |
+
last_tool_args_dict = tool_calls_parsed[-1].get("arguments", {}) if tool_calls_parsed else {}
|
| 348 |
+
store(user_input, final_reply, last_tool_name, last_tool_args_dict, last_tool_res)
|
| 349 |
+
else:
|
| 350 |
+
store(user_input, final_reply, "none", {}, {})
|
| 351 |
+
|
| 352 |
+
# Save conversation history (keep last 10 turns)
|
| 353 |
+
user_sessions[chat_id]["messages"].append({"role": "user", "content": user_input})
|
| 354 |
+
user_sessions[chat_id]["messages"].append({"role": "assistant", "content": final_reply})
|
| 355 |
+
if len(user_sessions[chat_id]["messages"]) > 20:
|
| 356 |
+
user_sessions[chat_id]["messages"] = user_sessions[chat_id]["messages"][-20:]
|
| 357 |
+
|
| 358 |
+
return final_reply
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# ββ Telegram handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 362 |
+
@bot.message_handler(commands=['start'])
|
| 363 |
+
def cmd_start(message):
|
| 364 |
+
name = message.from_user.first_name or "there"
|
| 365 |
+
bot.reply_to(message,
|
| 366 |
+
f"Hey {name}! π\n\n"
|
| 367 |
+
f"I'm your Jira Sprint Manager. I can help you:\n"
|
| 368 |
+
f"β’ View the backlog\n"
|
| 369 |
+
f"β’ Check active sprint progress\n"
|
| 370 |
+
f"β’ Create stories/tasks\n"
|
| 371 |
+
f"β’ Move issues between sprints\n"
|
| 372 |
+
f"β’ Transition issue status (To Do β In Progress β Testing β Done)\n"
|
| 373 |
+
f"β’ Close & rollover sprints\n\n"
|
| 374 |
+
f"Just ask me anything!"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
@bot.message_handler(commands=['memory'])
|
| 379 |
+
def cmd_memory(message):
|
| 380 |
+
"""Search Pinecone memory."""
|
| 381 |
+
parts = message.text.split(None, 1)
|
| 382 |
+
if len(parts) < 2:
|
| 383 |
+
bot.reply_to(message, "Usage: /memory <search query>")
|
| 384 |
+
return
|
| 385 |
+
query = parts[1]
|
| 386 |
+
result = recall(query, top_k=5)
|
| 387 |
+
if result:
|
| 388 |
+
# Truncate if too long for Telegram
|
| 389 |
+
reply = result[:4000]
|
| 390 |
+
bot.reply_to(message, reply)
|
| 391 |
+
else:
|
| 392 |
+
bot.reply_to(message, "Nothing found in memory.")
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
@bot.message_handler(func=lambda m: True)
|
| 396 |
+
def handle_message(message):
|
| 397 |
+
"""Handle all other messages β run the agent."""
|
| 398 |
+
user_input = message.text.strip()
|
| 399 |
+
chat_id = message.chat.id
|
| 400 |
+
username = message.from_user.first_name or message.from_user.username or "User"
|
| 401 |
+
|
| 402 |
+
if not user_input:
|
| 403 |
+
return
|
| 404 |
+
|
| 405 |
+
# Log user message to terminal for debugging
|
| 406 |
+
print(f"\n{'='*50}")
|
| 407 |
+
print(f" Telegram [{username}]: {user_input}")
|
| 408 |
+
print(f"{'='*50}\n")
|
| 409 |
+
|
| 410 |
+
# Show "typing" indicator
|
| 411 |
+
bot.send_chat_action(chat_id, "typing")
|
| 412 |
+
|
| 413 |
+
# Schedule the async agent turn on the event loop
|
| 414 |
+
future = asyncio.run_coroutine_threadsafe(
|
| 415 |
+
agent_turn_async(mcp_session_ref, openai_tools_ref, user_input, chat_id),
|
| 416 |
+
loop_ref,
|
| 417 |
+
)
|
| 418 |
+
reply = future.result(timeout=120) # wait up to 2 minutes
|
| 419 |
+
|
| 420 |
+
# Send reply (split if too long for Telegram's 4096 char limit)
|
| 421 |
+
chunks = [reply[i:i+4000] for i in range(0, len(reply), 4000)] if reply else ["..."]
|
| 422 |
+
for chunk in chunks:
|
| 423 |
+
bot.send_message(chat_id, chunk)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ββ main entry: start MCP + Telegram βββββββββββββββββββββββββββββββββββββββββ
|
| 427 |
+
mcp_session_ref = None
|
| 428 |
+
openai_tools_ref = None
|
| 429 |
+
|
| 430 |
+
async def main_async():
|
| 431 |
+
global mcp_session_ref, openai_tools_ref, loop_ref
|
| 432 |
+
loop_ref = asyncio.get_event_loop()
|
| 433 |
+
|
| 434 |
+
server_params = StdioServerParameters(
|
| 435 |
+
command=sys.executable,
|
| 436 |
+
args=[MCP_SERVER_SCRIPT],
|
| 437 |
+
env={**os.environ},
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
async with stdio_client(server_params) as (read, write):
|
| 441 |
+
async with ClientSession(read, write) as session:
|
| 442 |
+
await session.initialize()
|
| 443 |
+
|
| 444 |
+
tools_response = await session.list_tools()
|
| 445 |
+
mcp_tools = tools_response.tools
|
| 446 |
+
openai_tools = mcp_tools_to_openai_schema(mcp_tools)
|
| 447 |
+
|
| 448 |
+
# Store refs for the Telegram thread
|
| 449 |
+
mcp_session_ref = session
|
| 450 |
+
openai_tools_ref = openai_tools
|
| 451 |
+
|
| 452 |
+
stats = pine_index.describe_index_stats()
|
| 453 |
+
total = stats.get("total_vector_count", 0)
|
| 454 |
+
|
| 455 |
+
print("=" * 60)
|
| 456 |
+
print(" Jira Agent (Telegram + MCP) | Phi-4-Mini (local)")
|
| 457 |
+
print(f" MCP tools | {[t.name for t in mcp_tools]}")
|
| 458 |
+
print(f" Memory | {total} interactions in Pinecone")
|
| 459 |
+
print("=" * 60 + "\n")
|
| 460 |
+
|
| 461 |
+
# Start Telegram bot in a background thread
|
| 462 |
+
def run_telegram():
|
| 463 |
+
print(" Telegram bot started.\n")
|
| 464 |
+
bot.infinity_polling()
|
| 465 |
+
|
| 466 |
+
telegram_thread = threading.Thread(target=run_telegram, daemon=True)
|
| 467 |
+
telegram_thread.start()
|
| 468 |
+
|
| 469 |
+
# Keep the asyncio loop alive
|
| 470 |
+
try:
|
| 471 |
+
while True:
|
| 472 |
+
await asyncio.sleep(1)
|
| 473 |
+
except (KeyboardInterrupt, SystemExit):
|
| 474 |
+
print("\nShutting down...")
|
| 475 |
+
bot.stop_polling()
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
if __name__ == "__main__":
|
| 479 |
+
asyncio.run(main_async())
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|