gcharanteja commited on
Commit
631881d
Β·
1 Parent(s): 2c0e3f2
Files changed (9) hide show
  1. .python-version +1 -1
  2. Dockerfile +1 -1
  3. README.md +4 -0
  4. jira_mcp_server.py +141 -0
  5. main.py +37 -2
  6. pyproject.toml +16 -1
  7. server.py +474 -0
  8. telegram_agent.py +479 -0
  9. uv.lock +0 -0
.python-version CHANGED
@@ -1 +1 @@
1
- 3.13
 
1
+ 3.11
Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM python:3.12-slim
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
- def main():
5
- # Infinite loading animation
 
 
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.13"
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