mcp-ticketer 0.1.8__py3-none-any.whl → 0.1.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.1.8"
3
+ __version__ = "0.1.11"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -965,6 +965,269 @@ class GitHubAdapter(BaseAdapter[Task]):
965
965
 
966
966
  return response.status_code == 201
967
967
 
968
+ async def create_pull_request(
969
+ self,
970
+ ticket_id: str,
971
+ base_branch: str = "main",
972
+ head_branch: Optional[str] = None,
973
+ title: Optional[str] = None,
974
+ body: Optional[str] = None,
975
+ draft: bool = False,
976
+ ) -> Dict[str, Any]:
977
+ """Create a pull request linked to an issue.
978
+
979
+ Args:
980
+ ticket_id: Issue number to link the PR to
981
+ base_branch: Target branch for the PR (default: main)
982
+ head_branch: Source branch name (auto-generated if not provided)
983
+ title: PR title (uses ticket title if not provided)
984
+ body: PR description (auto-generated with issue link if not provided)
985
+ draft: Create as draft PR
986
+
987
+ Returns:
988
+ Dictionary with PR details including number, url, and branch
989
+ """
990
+ try:
991
+ issue_number = int(ticket_id)
992
+ except ValueError:
993
+ raise ValueError(f"Invalid issue number: {ticket_id}")
994
+
995
+ # Get the issue details
996
+ issue = await self.read(ticket_id)
997
+ if not issue:
998
+ raise ValueError(f"Issue #{ticket_id} not found")
999
+
1000
+ # Auto-generate branch name if not provided
1001
+ if not head_branch:
1002
+ # Create branch name from issue number and title
1003
+ # e.g., "123-fix-authentication-bug"
1004
+ safe_title = "-".join(
1005
+ issue.title.lower()
1006
+ .replace("[", "")
1007
+ .replace("]", "")
1008
+ .replace("#", "")
1009
+ .replace("/", "-")
1010
+ .replace("\\", "-")
1011
+ .split()[:5] # Limit to 5 words
1012
+ )
1013
+ head_branch = f"{issue_number}-{safe_title}"
1014
+
1015
+ # Auto-generate title if not provided
1016
+ if not title:
1017
+ # Include issue number in PR title
1018
+ title = f"[#{issue_number}] {issue.title}"
1019
+
1020
+ # Auto-generate body if not provided
1021
+ if not body:
1022
+ body = f"""## Summary
1023
+
1024
+ This PR addresses issue #{issue_number}.
1025
+
1026
+ **Issue:** #{issue_number} - {issue.title}
1027
+ **Link:** {issue.metadata.get('github', {}).get('url', '')}
1028
+
1029
+ ## Description
1030
+
1031
+ {issue.description or 'No description provided.'}
1032
+
1033
+ ## Changes
1034
+
1035
+ - [ ] Implementation details to be added
1036
+
1037
+ ## Testing
1038
+
1039
+ - [ ] Tests have been added/updated
1040
+ - [ ] All tests pass
1041
+
1042
+ ## Checklist
1043
+
1044
+ - [ ] Code follows project style guidelines
1045
+ - [ ] Self-review completed
1046
+ - [ ] Documentation updated if needed
1047
+
1048
+ Fixes #{issue_number}
1049
+ """
1050
+
1051
+ # Check if the head branch exists
1052
+ try:
1053
+ branch_response = await self.client.get(
1054
+ f"/repos/{self.owner}/{self.repo}/branches/{head_branch}"
1055
+ )
1056
+ branch_exists = branch_response.status_code == 200
1057
+ except httpx.HTTPError:
1058
+ branch_exists = False
1059
+
1060
+ if not branch_exists:
1061
+ # Get the base branch SHA
1062
+ base_response = await self.client.get(
1063
+ f"/repos/{self.owner}/{self.repo}/branches/{base_branch}"
1064
+ )
1065
+ base_response.raise_for_status()
1066
+ base_sha = base_response.json()["commit"]["sha"]
1067
+
1068
+ # Create the new branch
1069
+ ref_response = await self.client.post(
1070
+ f"/repos/{self.owner}/{self.repo}/git/refs",
1071
+ json={
1072
+ "ref": f"refs/heads/{head_branch}",
1073
+ "sha": base_sha,
1074
+ }
1075
+ )
1076
+
1077
+ if ref_response.status_code != 201:
1078
+ # Branch might already exist on remote, try to use it
1079
+ pass
1080
+
1081
+ # Create the pull request
1082
+ pr_data = {
1083
+ "title": title,
1084
+ "body": body,
1085
+ "head": head_branch,
1086
+ "base": base_branch,
1087
+ "draft": draft,
1088
+ }
1089
+
1090
+ pr_response = await self.client.post(
1091
+ f"/repos/{self.owner}/{self.repo}/pulls",
1092
+ json=pr_data
1093
+ )
1094
+
1095
+ if pr_response.status_code == 422:
1096
+ # PR might already exist, try to get it
1097
+ search_response = await self.client.get(
1098
+ f"/repos/{self.owner}/{self.repo}/pulls",
1099
+ params={
1100
+ "head": f"{self.owner}:{head_branch}",
1101
+ "base": base_branch,
1102
+ "state": "open",
1103
+ }
1104
+ )
1105
+
1106
+ if search_response.status_code == 200:
1107
+ existing_prs = search_response.json()
1108
+ if existing_prs:
1109
+ pr = existing_prs[0]
1110
+ return {
1111
+ "number": pr["number"],
1112
+ "url": pr["html_url"],
1113
+ "api_url": pr["url"],
1114
+ "branch": head_branch,
1115
+ "state": pr["state"],
1116
+ "draft": pr.get("draft", False),
1117
+ "title": pr["title"],
1118
+ "existing": True,
1119
+ "linked_issue": issue_number,
1120
+ }
1121
+
1122
+ raise ValueError(f"Failed to create PR: {pr_response.text}")
1123
+
1124
+ pr_response.raise_for_status()
1125
+ pr = pr_response.json()
1126
+
1127
+ # Add a comment to the issue about the PR
1128
+ await self.add_comment(
1129
+ Comment(
1130
+ ticket_id=ticket_id,
1131
+ content=f"Pull request #{pr['number']} has been created: {pr['html_url']}",
1132
+ author="system",
1133
+ )
1134
+ )
1135
+
1136
+ return {
1137
+ "number": pr["number"],
1138
+ "url": pr["html_url"],
1139
+ "api_url": pr["url"],
1140
+ "branch": head_branch,
1141
+ "state": pr["state"],
1142
+ "draft": pr.get("draft", False),
1143
+ "title": pr["title"],
1144
+ "linked_issue": issue_number,
1145
+ }
1146
+
1147
+ async def link_existing_pull_request(
1148
+ self,
1149
+ ticket_id: str,
1150
+ pr_url: str,
1151
+ ) -> Dict[str, Any]:
1152
+ """Link an existing pull request to a ticket.
1153
+
1154
+ Args:
1155
+ ticket_id: Issue number to link the PR to
1156
+ pr_url: GitHub PR URL to link
1157
+
1158
+ Returns:
1159
+ Dictionary with link status and PR details
1160
+ """
1161
+ try:
1162
+ issue_number = int(ticket_id)
1163
+ except ValueError:
1164
+ raise ValueError(f"Invalid issue number: {ticket_id}")
1165
+
1166
+ # Parse PR URL to extract owner, repo, and PR number
1167
+ # Expected format: https://github.com/owner/repo/pull/123
1168
+ import re
1169
+ pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
1170
+ match = re.search(pr_pattern, pr_url)
1171
+
1172
+ if not match:
1173
+ raise ValueError(f"Invalid GitHub PR URL format: {pr_url}")
1174
+
1175
+ pr_owner, pr_repo, pr_number = match.groups()
1176
+
1177
+ # Verify the PR is from the same repository
1178
+ if pr_owner != self.owner or pr_repo != self.repo:
1179
+ raise ValueError(
1180
+ f"PR must be from the same repository ({self.owner}/{self.repo})"
1181
+ )
1182
+
1183
+ # Get PR details
1184
+ pr_response = await self.client.get(
1185
+ f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
1186
+ )
1187
+
1188
+ if pr_response.status_code == 404:
1189
+ raise ValueError(f"Pull request #{pr_number} not found")
1190
+
1191
+ pr_response.raise_for_status()
1192
+ pr = pr_response.json()
1193
+
1194
+ # Update PR body to include issue reference if not already present
1195
+ current_body = pr.get("body", "")
1196
+ issue_ref = f"#{issue_number}"
1197
+
1198
+ if issue_ref not in current_body:
1199
+ # Add issue reference to the body
1200
+ updated_body = current_body or ""
1201
+ if updated_body:
1202
+ updated_body += "\n\n"
1203
+ updated_body += f"Related to #{issue_number}"
1204
+
1205
+ # Update the PR
1206
+ update_response = await self.client.patch(
1207
+ f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}",
1208
+ json={"body": updated_body}
1209
+ )
1210
+ update_response.raise_for_status()
1211
+
1212
+ # Add a comment to the issue about the PR
1213
+ await self.add_comment(
1214
+ Comment(
1215
+ ticket_id=ticket_id,
1216
+ content=f"Linked to pull request #{pr_number}: {pr_url}",
1217
+ author="system",
1218
+ )
1219
+ )
1220
+
1221
+ return {
1222
+ "success": True,
1223
+ "pr_number": pr["number"],
1224
+ "pr_url": pr["html_url"],
1225
+ "pr_title": pr["title"],
1226
+ "pr_state": pr["state"],
1227
+ "linked_issue": issue_number,
1228
+ "message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
1229
+ }
1230
+
968
1231
  async def close(self) -> None:
969
1232
  """Close the HTTP client connection."""
970
1233
  await self.client.aclose()
@@ -1343,6 +1343,226 @@ class LinearAdapter(BaseAdapter[Task]):
1343
1343
 
1344
1344
  return result.get("reactionCreate", {}).get("success", False)
1345
1345
 
1346
+ async def link_to_pull_request(
1347
+ self,
1348
+ ticket_id: str,
1349
+ pr_url: str,
1350
+ pr_number: Optional[int] = None,
1351
+ ) -> Dict[str, Any]:
1352
+ """Link a Linear issue to a GitHub pull request.
1353
+
1354
+ Args:
1355
+ ticket_id: Linear issue identifier (e.g., 'BTA-123')
1356
+ pr_url: GitHub PR URL
1357
+ pr_number: Optional PR number (extracted from URL if not provided)
1358
+
1359
+ Returns:
1360
+ Dictionary with link status and details
1361
+ """
1362
+ # Parse PR URL to extract details
1363
+ import re
1364
+ pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
1365
+ match = re.search(pr_pattern, pr_url)
1366
+
1367
+ if not match:
1368
+ raise ValueError(f"Invalid GitHub PR URL format: {pr_url}")
1369
+
1370
+ owner, repo, extracted_pr_number = match.groups()
1371
+ if not pr_number:
1372
+ pr_number = int(extracted_pr_number)
1373
+
1374
+ # Create an attachment to link the PR
1375
+ create_query = gql("""
1376
+ mutation CreateAttachment($input: AttachmentCreateInput!) {
1377
+ attachmentCreate(input: $input) {
1378
+ attachment {
1379
+ id
1380
+ url
1381
+ title
1382
+ subtitle
1383
+ source
1384
+ }
1385
+ success
1386
+ }
1387
+ }
1388
+ """)
1389
+
1390
+ # Get the issue ID from the identifier
1391
+ issue = await self.read(ticket_id)
1392
+ if not issue:
1393
+ raise ValueError(f"Issue {ticket_id} not found")
1394
+
1395
+ # Create attachment input
1396
+ attachment_input = {
1397
+ "issueId": issue.metadata.get("linear", {}).get("id"),
1398
+ "url": pr_url,
1399
+ "title": f"Pull Request #{pr_number}",
1400
+ "subtitle": f"{owner}/{repo}",
1401
+ "source": {
1402
+ "type": "githubPr",
1403
+ "data": {
1404
+ "number": pr_number,
1405
+ "owner": owner,
1406
+ "repo": repo,
1407
+ }
1408
+ },
1409
+ }
1410
+
1411
+ async with self.client as session:
1412
+ result = await session.execute(
1413
+ create_query,
1414
+ variable_values={"input": attachment_input}
1415
+ )
1416
+
1417
+ if result.get("attachmentCreate", {}).get("success"):
1418
+ attachment = result["attachmentCreate"]["attachment"]
1419
+
1420
+ # Also add a comment about the PR link
1421
+ comment_text = f"Linked to GitHub PR: {pr_url}"
1422
+ await self.add_comment(
1423
+ Comment(
1424
+ ticket_id=ticket_id,
1425
+ content=comment_text,
1426
+ author="system",
1427
+ )
1428
+ )
1429
+
1430
+ return {
1431
+ "success": True,
1432
+ "attachment_id": attachment["id"],
1433
+ "pr_url": pr_url,
1434
+ "pr_number": pr_number,
1435
+ "linked_issue": ticket_id,
1436
+ "message": f"Successfully linked PR #{pr_number} to issue {ticket_id}",
1437
+ }
1438
+ else:
1439
+ return {
1440
+ "success": False,
1441
+ "pr_url": pr_url,
1442
+ "pr_number": pr_number,
1443
+ "linked_issue": ticket_id,
1444
+ "message": "Failed to create attachment link",
1445
+ }
1446
+
1447
+ async def create_pull_request_for_issue(
1448
+ self,
1449
+ ticket_id: str,
1450
+ github_config: Dict[str, Any],
1451
+ ) -> Dict[str, Any]:
1452
+ """Create a GitHub PR for a Linear issue using GitHub integration.
1453
+
1454
+ This requires GitHub integration to be configured in Linear.
1455
+
1456
+ Args:
1457
+ ticket_id: Linear issue identifier
1458
+ github_config: GitHub configuration including:
1459
+ - owner: GitHub repository owner
1460
+ - repo: GitHub repository name
1461
+ - base_branch: Target branch (default: main)
1462
+ - head_branch: Source branch (auto-generated if not provided)
1463
+
1464
+ Returns:
1465
+ Dictionary with PR creation status
1466
+ """
1467
+ # Get the issue details
1468
+ issue = await self.read(ticket_id)
1469
+ if not issue:
1470
+ raise ValueError(f"Issue {ticket_id} not found")
1471
+
1472
+ # Generate branch name if not provided
1473
+ head_branch = github_config.get("head_branch")
1474
+ if not head_branch:
1475
+ # Use Linear's branch naming convention
1476
+ # e.g., "bta-123-fix-authentication-bug"
1477
+ safe_title = "-".join(
1478
+ issue.title.lower()
1479
+ .replace("[", "")
1480
+ .replace("]", "")
1481
+ .replace("#", "")
1482
+ .replace("/", "-")
1483
+ .replace("\\", "-")
1484
+ .split()[:5] # Limit to 5 words
1485
+ )
1486
+ head_branch = f"{ticket_id.lower()}-{safe_title}"
1487
+
1488
+ # Update the issue with the branch name
1489
+ update_query = gql("""
1490
+ mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
1491
+ issueUpdate(id: $id, input: $input) {
1492
+ issue {
1493
+ id
1494
+ identifier
1495
+ branchName
1496
+ }
1497
+ success
1498
+ }
1499
+ }
1500
+ """)
1501
+
1502
+ linear_id = issue.metadata.get("linear", {}).get("id")
1503
+ if not linear_id:
1504
+ # Need to get the full issue ID
1505
+ search_result = await self._search_by_identifier(ticket_id)
1506
+ if not search_result:
1507
+ raise ValueError(f"Could not find Linear ID for issue {ticket_id}")
1508
+ linear_id = search_result["id"]
1509
+
1510
+ async with self.client as session:
1511
+ result = await session.execute(
1512
+ update_query,
1513
+ variable_values={
1514
+ "id": linear_id,
1515
+ "input": {"branchName": head_branch}
1516
+ }
1517
+ )
1518
+
1519
+ if result.get("issueUpdate", {}).get("success"):
1520
+ # Prepare PR metadata to return
1521
+ pr_metadata = {
1522
+ "branch_name": head_branch,
1523
+ "issue_id": ticket_id,
1524
+ "issue_title": issue.title,
1525
+ "issue_description": issue.description,
1526
+ "github_owner": github_config.get("owner"),
1527
+ "github_repo": github_config.get("repo"),
1528
+ "base_branch": github_config.get("base_branch", "main"),
1529
+ "message": f"Branch name '{head_branch}' set for issue {ticket_id}. Use GitHub integration or API to create the actual PR.",
1530
+ }
1531
+
1532
+ # Add a comment about the branch
1533
+ await self.add_comment(
1534
+ Comment(
1535
+ ticket_id=ticket_id,
1536
+ content=f"Branch created: `{head_branch}`\nReady for pull request to `{pr_metadata['base_branch']}`",
1537
+ author="system",
1538
+ )
1539
+ )
1540
+
1541
+ return pr_metadata
1542
+ else:
1543
+ raise ValueError(f"Failed to update issue {ticket_id} with branch name")
1544
+
1545
+ async def _search_by_identifier(self, identifier: str) -> Optional[Dict[str, Any]]:
1546
+ """Search for an issue by its identifier."""
1547
+ search_query = gql("""
1548
+ query SearchIssue($identifier: String!) {
1549
+ issue(id: $identifier) {
1550
+ id
1551
+ identifier
1552
+ }
1553
+ }
1554
+ """)
1555
+
1556
+ try:
1557
+ async with self.client as session:
1558
+ result = await session.execute(
1559
+ search_query,
1560
+ variable_values={"identifier": identifier}
1561
+ )
1562
+ return result.get("issue")
1563
+ except:
1564
+ return None
1565
+
1346
1566
  async def close(self) -> None:
1347
1567
  """Close the GraphQL client connection."""
1348
1568
  if hasattr(self.client, 'close_async'):
mcp_ticketer/cli/main.py CHANGED
@@ -18,6 +18,7 @@ from ..core.models import SearchQuery
18
18
  from ..adapters import AITrackdownAdapter
19
19
  from ..queue import Queue, QueueStatus, WorkerManager
20
20
  from .queue_commands import app as queue_app
21
+ from ..__version__ import __version__
21
22
 
22
23
  # Load environment variables
23
24
  load_dotenv()
@@ -29,6 +30,31 @@ app = typer.Typer(
29
30
  )
30
31
  console = Console()
31
32
 
33
+
34
+ def version_callback(value: bool):
35
+ """Print version and exit."""
36
+ if value:
37
+ console.print(f"mcp-ticketer version {__version__}")
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def main_callback(
43
+ version: bool = typer.Option(
44
+ None,
45
+ "--version",
46
+ "-v",
47
+ callback=version_callback,
48
+ is_eager=True,
49
+ help="Show version and exit"
50
+ ),
51
+ ):
52
+ """
53
+ MCP Ticketer - Universal ticket management interface.
54
+ """
55
+ pass
56
+
57
+
32
58
  # Configuration file management
33
59
  CONFIG_FILE = Path.home() / ".mcp-ticketer" / "config.json"
34
60
 
@@ -63,6 +63,10 @@ class MCPTicketServer:
63
63
  result = await self._handle_comment(params)
64
64
  elif method == "ticket/status":
65
65
  result = await self._handle_queue_status(params)
66
+ elif method == "ticket/create_pr":
67
+ result = await self._handle_create_pr(params)
68
+ elif method == "ticket/link_pr":
69
+ result = await self._handle_link_pr(params)
66
70
  elif method == "tools/list":
67
71
  result = await self._handle_tools_list()
68
72
  elif method == "tools/call":
@@ -134,11 +138,72 @@ class MCPTicketServer:
134
138
  manager = WorkerManager()
135
139
  manager.start_if_needed()
136
140
 
137
- return {
138
- "queue_id": queue_id,
139
- "status": "queued",
140
- "message": f"Ticket creation queued with ID: {queue_id}"
141
- }
141
+ # Check if async mode is requested (for backward compatibility)
142
+ if params.get("async_mode", False):
143
+ return {
144
+ "queue_id": queue_id,
145
+ "status": "queued",
146
+ "message": f"Ticket creation queued with ID: {queue_id}"
147
+ }
148
+
149
+ # Poll for completion with timeout (default synchronous behavior)
150
+ max_wait_time = params.get("timeout", 30) # seconds, allow override
151
+ poll_interval = 0.5 # seconds
152
+ start_time = asyncio.get_event_loop().time()
153
+
154
+ while True:
155
+ # Check queue status
156
+ item = queue.get_item(queue_id)
157
+
158
+ if not item:
159
+ return {
160
+ "queue_id": queue_id,
161
+ "status": "error",
162
+ "error": f"Queue item {queue_id} not found"
163
+ }
164
+
165
+ # If completed, return with ticket ID
166
+ if item.status == QueueStatus.COMPLETED:
167
+ response = {
168
+ "queue_id": queue_id,
169
+ "status": "completed",
170
+ "title": params["title"]
171
+ }
172
+
173
+ # Add ticket ID and other result data if available
174
+ if item.result:
175
+ response["ticket_id"] = item.result.get("id")
176
+ if "state" in item.result:
177
+ response["state"] = item.result["state"]
178
+ # Try to construct URL if we have enough information
179
+ if response.get("ticket_id"):
180
+ # This is adapter-specific, but we can add URL generation later
181
+ response["id"] = response["ticket_id"] # Also include as "id" for compatibility
182
+
183
+ response["message"] = f"Ticket created successfully: {response.get('ticket_id', queue_id)}"
184
+ return response
185
+
186
+ # If failed, return error
187
+ if item.status == QueueStatus.FAILED:
188
+ return {
189
+ "queue_id": queue_id,
190
+ "status": "failed",
191
+ "error": item.error_message or "Ticket creation failed",
192
+ "title": params["title"]
193
+ }
194
+
195
+ # Check timeout
196
+ elapsed = asyncio.get_event_loop().time() - start_time
197
+ if elapsed > max_wait_time:
198
+ return {
199
+ "queue_id": queue_id,
200
+ "status": "timeout",
201
+ "message": f"Ticket creation timed out after {max_wait_time} seconds. Use ticket_status with queue_id to check status.",
202
+ "title": params["title"]
203
+ }
204
+
205
+ # Wait before next poll
206
+ await asyncio.sleep(poll_interval)
142
207
 
143
208
  async def _handle_read(self, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
144
209
  """Handle ticket read."""
@@ -162,11 +227,60 @@ class MCPTicketServer:
162
227
  manager = WorkerManager()
163
228
  manager.start_if_needed()
164
229
 
165
- return {
166
- "queue_id": queue_id,
167
- "status": "queued",
168
- "message": f"Ticket update queued with ID: {queue_id}"
169
- }
230
+ # Poll for completion with timeout
231
+ max_wait_time = 30 # seconds
232
+ poll_interval = 0.5 # seconds
233
+ start_time = asyncio.get_event_loop().time()
234
+
235
+ while True:
236
+ # Check queue status
237
+ item = queue.get_item(queue_id)
238
+
239
+ if not item:
240
+ return {
241
+ "queue_id": queue_id,
242
+ "status": "error",
243
+ "error": f"Queue item {queue_id} not found"
244
+ }
245
+
246
+ # If completed, return with ticket ID
247
+ if item.status == QueueStatus.COMPLETED:
248
+ response = {
249
+ "queue_id": queue_id,
250
+ "status": "completed",
251
+ "ticket_id": params["ticket_id"]
252
+ }
253
+
254
+ # Add result data if available
255
+ if item.result:
256
+ if item.result.get("id"):
257
+ response["ticket_id"] = item.result["id"]
258
+ response["success"] = item.result.get("success", True)
259
+
260
+ response["message"] = f"Ticket updated successfully: {response['ticket_id']}"
261
+ return response
262
+
263
+ # If failed, return error
264
+ if item.status == QueueStatus.FAILED:
265
+ return {
266
+ "queue_id": queue_id,
267
+ "status": "failed",
268
+ "error": item.error_message or "Ticket update failed",
269
+ "ticket_id": params["ticket_id"]
270
+ }
271
+
272
+ # Check timeout
273
+ elapsed = asyncio.get_event_loop().time() - start_time
274
+ if elapsed > max_wait_time:
275
+ return {
276
+ "queue_id": queue_id,
277
+ "status": "timeout",
278
+ "message": f"Ticket update timed out after {max_wait_time} seconds. Use ticket_status with queue_id to check status.",
279
+ "ticket_id": params["ticket_id"]
280
+ }
281
+
282
+ # Wait before next poll
283
+ await asyncio.sleep(poll_interval)
170
284
 
171
285
  async def _handle_delete(self, params: Dict[str, Any]) -> Dict[str, Any]:
172
286
  """Handle ticket deletion."""
@@ -220,11 +334,61 @@ class MCPTicketServer:
220
334
  manager = WorkerManager()
221
335
  manager.start_if_needed()
222
336
 
223
- return {
224
- "queue_id": queue_id,
225
- "status": "queued",
226
- "message": f"State transition queued with ID: {queue_id}"
227
- }
337
+ # Poll for completion with timeout
338
+ max_wait_time = 30 # seconds
339
+ poll_interval = 0.5 # seconds
340
+ start_time = asyncio.get_event_loop().time()
341
+
342
+ while True:
343
+ # Check queue status
344
+ item = queue.get_item(queue_id)
345
+
346
+ if not item:
347
+ return {
348
+ "queue_id": queue_id,
349
+ "status": "error",
350
+ "error": f"Queue item {queue_id} not found"
351
+ }
352
+
353
+ # If completed, return with ticket ID
354
+ if item.status == QueueStatus.COMPLETED:
355
+ response = {
356
+ "queue_id": queue_id,
357
+ "status": "completed",
358
+ "ticket_id": params["ticket_id"],
359
+ "state": params["target_state"]
360
+ }
361
+
362
+ # Add result data if available
363
+ if item.result:
364
+ if item.result.get("id"):
365
+ response["ticket_id"] = item.result["id"]
366
+ response["success"] = item.result.get("success", True)
367
+
368
+ response["message"] = f"State transition completed successfully: {response['ticket_id']} → {params['target_state']}"
369
+ return response
370
+
371
+ # If failed, return error
372
+ if item.status == QueueStatus.FAILED:
373
+ return {
374
+ "queue_id": queue_id,
375
+ "status": "failed",
376
+ "error": item.error_message or "State transition failed",
377
+ "ticket_id": params["ticket_id"]
378
+ }
379
+
380
+ # Check timeout
381
+ elapsed = asyncio.get_event_loop().time() - start_time
382
+ if elapsed > max_wait_time:
383
+ return {
384
+ "queue_id": queue_id,
385
+ "status": "timeout",
386
+ "message": f"State transition timed out after {max_wait_time} seconds. Use ticket_status with queue_id to check status.",
387
+ "ticket_id": params["ticket_id"]
388
+ }
389
+
390
+ # Wait before next poll
391
+ await asyncio.sleep(poll_interval)
228
392
 
229
393
  async def _handle_comment(self, params: Dict[str, Any]) -> Dict[str, Any]:
230
394
  """Handle comment operations."""
@@ -298,6 +462,144 @@ class MCPTicketServer:
298
462
 
299
463
  return response
300
464
 
465
+ async def _handle_create_pr(self, params: Dict[str, Any]) -> Dict[str, Any]:
466
+ """Handle PR creation for a ticket."""
467
+ ticket_id = params.get("ticket_id")
468
+ if not ticket_id:
469
+ raise ValueError("ticket_id is required")
470
+
471
+ # Check if adapter supports PR creation
472
+ adapter_name = self.adapter.__class__.__name__.lower()
473
+
474
+ if "github" in adapter_name:
475
+ # GitHub adapter supports direct PR creation
476
+ from ..adapters.github import GitHubAdapter
477
+ if isinstance(self.adapter, GitHubAdapter):
478
+ try:
479
+ result = await self.adapter.create_pull_request(
480
+ ticket_id=ticket_id,
481
+ base_branch=params.get("base_branch", "main"),
482
+ head_branch=params.get("head_branch"),
483
+ title=params.get("title"),
484
+ body=params.get("body"),
485
+ draft=params.get("draft", False),
486
+ )
487
+ return {
488
+ "success": True,
489
+ "pr_number": result.get("number"),
490
+ "pr_url": result.get("url"),
491
+ "branch": result.get("branch"),
492
+ "linked_issue": result.get("linked_issue"),
493
+ "message": f"Pull request created successfully: {result.get('url')}",
494
+ }
495
+ except Exception as e:
496
+ return {
497
+ "success": False,
498
+ "error": str(e),
499
+ "ticket_id": ticket_id,
500
+ }
501
+ elif "linear" in adapter_name:
502
+ # Linear adapter needs GitHub config for PR creation
503
+ from ..adapters.linear import LinearAdapter
504
+ if isinstance(self.adapter, LinearAdapter):
505
+ # For Linear, we prepare the branch and metadata but can't create the actual PR
506
+ # without GitHub integration configured
507
+ try:
508
+ github_config = {
509
+ "owner": params.get("github_owner"),
510
+ "repo": params.get("github_repo"),
511
+ "base_branch": params.get("base_branch", "main"),
512
+ "head_branch": params.get("head_branch"),
513
+ }
514
+
515
+ # Validate GitHub config for Linear
516
+ if not github_config.get("owner") or not github_config.get("repo"):
517
+ return {
518
+ "success": False,
519
+ "error": "GitHub owner and repo are required for Linear PR creation",
520
+ "ticket_id": ticket_id,
521
+ }
522
+
523
+ result = await self.adapter.create_pull_request_for_issue(
524
+ ticket_id=ticket_id,
525
+ github_config=github_config,
526
+ )
527
+ return {
528
+ "success": True,
529
+ "branch_name": result.get("branch_name"),
530
+ "ticket_id": ticket_id,
531
+ "message": result.get("message"),
532
+ "github_config": {
533
+ "owner": result.get("github_owner"),
534
+ "repo": result.get("github_repo"),
535
+ "base_branch": result.get("base_branch"),
536
+ },
537
+ }
538
+ except Exception as e:
539
+ return {
540
+ "success": False,
541
+ "error": str(e),
542
+ "ticket_id": ticket_id,
543
+ }
544
+ else:
545
+ return {
546
+ "success": False,
547
+ "error": f"PR creation not supported for adapter: {adapter_name}",
548
+ "ticket_id": ticket_id,
549
+ }
550
+
551
+ async def _handle_link_pr(self, params: Dict[str, Any]) -> Dict[str, Any]:
552
+ """Handle linking an existing PR to a ticket."""
553
+ ticket_id = params.get("ticket_id")
554
+ pr_url = params.get("pr_url")
555
+
556
+ if not ticket_id:
557
+ raise ValueError("ticket_id is required")
558
+ if not pr_url:
559
+ raise ValueError("pr_url is required")
560
+
561
+ adapter_name = self.adapter.__class__.__name__.lower()
562
+
563
+ if "github" in adapter_name:
564
+ from ..adapters.github import GitHubAdapter
565
+ if isinstance(self.adapter, GitHubAdapter):
566
+ try:
567
+ result = await self.adapter.link_existing_pull_request(
568
+ ticket_id=ticket_id,
569
+ pr_url=pr_url,
570
+ )
571
+ return result
572
+ except Exception as e:
573
+ return {
574
+ "success": False,
575
+ "error": str(e),
576
+ "ticket_id": ticket_id,
577
+ "pr_url": pr_url,
578
+ }
579
+ elif "linear" in adapter_name:
580
+ from ..adapters.linear import LinearAdapter
581
+ if isinstance(self.adapter, LinearAdapter):
582
+ try:
583
+ result = await self.adapter.link_to_pull_request(
584
+ ticket_id=ticket_id,
585
+ pr_url=pr_url,
586
+ )
587
+ return result
588
+ except Exception as e:
589
+ return {
590
+ "success": False,
591
+ "error": str(e),
592
+ "ticket_id": ticket_id,
593
+ "pr_url": pr_url,
594
+ }
595
+ else:
596
+ return {
597
+ "success": False,
598
+ "error": f"PR linking not supported for adapter: {adapter_name}",
599
+ "ticket_id": ticket_id,
600
+ "pr_url": pr_url,
601
+ }
602
+
301
603
  async def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
302
604
  """Handle initialize request from MCP client.
303
605
 
@@ -324,6 +626,34 @@ class MCPTicketServer:
324
626
  """List available MCP tools."""
325
627
  return {
326
628
  "tools": [
629
+ {
630
+ "name": "ticket_create_pr",
631
+ "description": "Create a GitHub PR linked to a ticket",
632
+ "inputSchema": {
633
+ "type": "object",
634
+ "properties": {
635
+ "ticket_id": {"type": "string", "description": "Ticket ID to link the PR to"},
636
+ "base_branch": {"type": "string", "description": "Target branch for the PR", "default": "main"},
637
+ "head_branch": {"type": "string", "description": "Source branch name (auto-generated if not provided)"},
638
+ "title": {"type": "string", "description": "PR title (uses ticket title if not provided)"},
639
+ "body": {"type": "string", "description": "PR description (auto-generated with issue link if not provided)"},
640
+ "draft": {"type": "boolean", "description": "Create as draft PR", "default": False},
641
+ },
642
+ "required": ["ticket_id"]
643
+ }
644
+ },
645
+ {
646
+ "name": "ticket_link_pr",
647
+ "description": "Link an existing PR to a ticket",
648
+ "inputSchema": {
649
+ "type": "object",
650
+ "properties": {
651
+ "ticket_id": {"type": "string", "description": "Ticket ID to link the PR to"},
652
+ "pr_url": {"type": "string", "description": "GitHub PR URL to link"},
653
+ },
654
+ "required": ["ticket_id", "pr_url"]
655
+ }
656
+ },
327
657
  {
328
658
  "name": "ticket_create",
329
659
  "description": "Create a new ticket",
@@ -428,6 +758,10 @@ class MCPTicketServer:
428
758
  result = await self._handle_search(arguments)
429
759
  elif tool_name == "ticket_status":
430
760
  result = await self._handle_queue_status(arguments)
761
+ elif tool_name == "ticket_create_pr":
762
+ result = await self._handle_create_pr(arguments)
763
+ elif tool_name == "ticket_link_pr":
764
+ result = await self._handle_link_pr(arguments)
431
765
  else:
432
766
  return {
433
767
  "content": [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-ticketer
3
- Version: 0.1.8
3
+ Version: 0.1.11
4
4
  Summary: Universal ticket management interface for AI agents with MCP support
5
5
  Author-email: MCP Ticketer Team <support@mcp-ticketer.io>
6
6
  Maintainer-email: MCP Ticketer Team <support@mcp-ticketer.io>
@@ -33,13 +33,14 @@ Classifier: Typing :: Typed
33
33
  Requires-Python: >=3.9
34
34
  Description-Content-Type: text/markdown
35
35
  License-File: LICENSE
36
+ Requires-Dist: gql[httpx]>=3.0.0
37
+ Requires-Dist: httpx>=0.25.0
38
+ Requires-Dist: psutil>=5.9.0
36
39
  Requires-Dist: pydantic>=2.0
37
- Requires-Dist: typer>=0.9.0
40
+ Requires-Dist: python-dotenv>=1.0.0
38
41
  Requires-Dist: rich>=13.0.0
39
- Requires-Dist: httpx>=0.25.0
42
+ Requires-Dist: typer>=0.9.0
40
43
  Requires-Dist: typing-extensions>=4.8.0
41
- Requires-Dist: python-dotenv>=1.0.0
42
- Requires-Dist: psutil>=5.9.0
43
44
  Provides-Extra: all
44
45
  Requires-Dist: mcp-ticketer[github,jira,linear,mcp]; extra == "all"
45
46
  Provides-Extra: dev
@@ -1,15 +1,15 @@
1
1
  mcp_ticketer/__init__.py,sha256=ayPQdFr6msypD06_G96a1H0bdFCT1m1wDtv8MZBpY4I,496
2
- mcp_ticketer/__version__.py,sha256=xaUaMuuCEFDRyWYQ--OHg6JhbB17oO3kulskm7AkcVA,1114
2
+ mcp_ticketer/__version__.py,sha256=Ym9ng_TEiLSfqVAhPCtwjrIqiM8S9cAUurRI6mpYEyg,1115
3
3
  mcp_ticketer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  mcp_ticketer/adapters/__init__.py,sha256=_QRLaX38EUsL-kMvJITY0lYHvrq_ip9Qw4Q1YLavJSo,283
5
5
  mcp_ticketer/adapters/aitrackdown.py,sha256=ICNimTtF6rPajuVoVEpmdw2TfjYjnWvao8prUwukNn0,15210
6
- mcp_ticketer/adapters/github.py,sha256=5vv_BwjPTJOY0sLFQv342Cz2u8PtS51RV7AK0-NEsLo,35357
6
+ mcp_ticketer/adapters/github.py,sha256=onT8NhYaf9fIw2eCOTbZSkk7q4IoM7ZADRvRl9qrUz8,43850
7
7
  mcp_ticketer/adapters/jira.py,sha256=rd-8PseTsRyQNPjsrUJ8vJ8vfBpa6HWFBieOUyvw0Tg,28954
8
- mcp_ticketer/adapters/linear.py,sha256=PYqR30h223un50_Z-0lepPyIasK2uVHyL11mfPqtWV0,44119
8
+ mcp_ticketer/adapters/linear.py,sha256=neTxVy-QD23tTI7XKtnc5CBCpm3yVCULlgxG5oFSQI4,51752
9
9
  mcp_ticketer/cache/__init__.py,sha256=MSi3GLXancfP2-edPC9TFAJk7r0j6H5-XmpMHnkGPbI,137
10
10
  mcp_ticketer/cache/memory.py,sha256=gTzv-xF7qGfiYVUjG7lnzo0ZcqgXQajMl4NAYUcaytg,5133
11
11
  mcp_ticketer/cli/__init__.py,sha256=YeljyLtv906TqkvRuEPhmKO-Uk0CberQ9I6kx1tx2UA,88
12
- mcp_ticketer/cli/main.py,sha256=sGQhyaPZhCZpHM5-1aqaW3KJTfkSv2LteIKlXdavkSQ,27774
12
+ mcp_ticketer/cli/main.py,sha256=adz3YXGnw7GY1h9jYgmIfYizf-z4ddhx2adFjncpZ3A,28294
13
13
  mcp_ticketer/cli/queue_commands.py,sha256=f3pEHKZ43dBHEIoCBvdfvjfMB9_WJltps9ATwTzorY0,8160
14
14
  mcp_ticketer/cli/utils.py,sha256=NxsS91vKA8xZnDXKU2y0Gcyc4I_ctRyJj-wT7Xd1Q_Q,18589
15
15
  mcp_ticketer/core/__init__.py,sha256=NA-rDvwuAOZ9sUZVYJOWp8bR6mOBG8w_5lpWTT75JNI,318
@@ -20,16 +20,16 @@ mcp_ticketer/core/mappers.py,sha256=8I4jcqDqoQEdWlteDMpVeVF3Wo0fDCkmFPRr8oNv8gA,
20
20
  mcp_ticketer/core/models.py,sha256=K-bLuU_DNNVgjHnVFzAIbSa0kJwT2I3Hj24sCklwIYo,4374
21
21
  mcp_ticketer/core/registry.py,sha256=fwje0fnjp0YKPZ0SrVWk82SMNLs7CD0JlHQmx7SigNo,3537
22
22
  mcp_ticketer/mcp/__init__.py,sha256=Bvzof9vBu6VwcXcIZK8RgKv6ycRV9tDlO-9TUmd8zqQ,122
23
- mcp_ticketer/mcp/server.py,sha256=1sl_JZCwqlgOS53cisVNGIg6a2p7H2geC4c6h7g-UvA,19574
23
+ mcp_ticketer/mcp/server.py,sha256=TDuU8ChZC2QYSRo0uGHkVReblTf--hriOjxo-pSAF_Y,34068
24
24
  mcp_ticketer/queue/__init__.py,sha256=xHBoUwor8ZdO8bIHc7nP25EsAp5Si5Co4g_8ybb7fes,230
25
25
  mcp_ticketer/queue/__main__.py,sha256=kQd6iOCKbbFqpRdbIRavuI4_G7-oE898JE4a0yLEYPE,108
26
26
  mcp_ticketer/queue/manager.py,sha256=79AH9oUxdBXH3lmJ3kIlFf2GQkWHL6XB6u5JqVWPq60,7571
27
27
  mcp_ticketer/queue/queue.py,sha256=UgbIChWPiyI7BJNQ9DYA92D2jVMMtmVWBzotI5ML51A,11394
28
28
  mcp_ticketer/queue/run_worker.py,sha256=HFoykfDpOoz8OUxWbQ2Fka_UlGrYwjPVZ-DEimGFH9o,802
29
29
  mcp_ticketer/queue/worker.py,sha256=cVjHR_kfnGKAkiUg0HuXCnbKeKNBBEuj0XZHgIuIn4k,14017
30
- mcp_ticketer-0.1.8.dist-info/licenses/LICENSE,sha256=KOVrunjtILSzY-2N8Lqa3-Q8dMaZIG4LrlLTr9UqL08,1073
31
- mcp_ticketer-0.1.8.dist-info/METADATA,sha256=cD0ozxekpO4uT0ychGnrtCNJ7Tig8NwrotX-T2Ri_RA,11177
32
- mcp_ticketer-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
- mcp_ticketer-0.1.8.dist-info/entry_points.txt,sha256=o1IxVhnHnBNG7FZzbFq-Whcs1Djbofs0qMjiUYBLx2s,60
34
- mcp_ticketer-0.1.8.dist-info/top_level.txt,sha256=WnAG4SOT1Vm9tIwl70AbGG_nA217YyV3aWFhxLH2rxw,13
35
- mcp_ticketer-0.1.8.dist-info/RECORD,,
30
+ mcp_ticketer-0.1.11.dist-info/licenses/LICENSE,sha256=KOVrunjtILSzY-2N8Lqa3-Q8dMaZIG4LrlLTr9UqL08,1073
31
+ mcp_ticketer-0.1.11.dist-info/METADATA,sha256=GjLKPEBTziutLdEpztLyQkGvxMCpiHArYsdSKltJwwA,11211
32
+ mcp_ticketer-0.1.11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
+ mcp_ticketer-0.1.11.dist-info/entry_points.txt,sha256=o1IxVhnHnBNG7FZzbFq-Whcs1Djbofs0qMjiUYBLx2s,60
34
+ mcp_ticketer-0.1.11.dist-info/top_level.txt,sha256=WnAG4SOT1Vm9tIwl70AbGG_nA217YyV3aWFhxLH2rxw,13
35
+ mcp_ticketer-0.1.11.dist-info/RECORD,,