mcp-ticketer 0.4.11__py3-none-any.whl → 0.12.0__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.

Files changed (70) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +9 -3
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +313 -96
  10. mcp_ticketer/adapters/jira.py +251 -1
  11. mcp_ticketer/adapters/linear/adapter.py +524 -22
  12. mcp_ticketer/adapters/linear/client.py +61 -9
  13. mcp_ticketer/adapters/linear/mappers.py +9 -3
  14. mcp_ticketer/cache/memory.py +3 -3
  15. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  16. mcp_ticketer/cli/auggie_configure.py +1 -1
  17. mcp_ticketer/cli/codex_configure.py +80 -1
  18. mcp_ticketer/cli/configure.py +33 -43
  19. mcp_ticketer/cli/diagnostics.py +18 -16
  20. mcp_ticketer/cli/discover.py +288 -21
  21. mcp_ticketer/cli/gemini_configure.py +1 -1
  22. mcp_ticketer/cli/instruction_commands.py +429 -0
  23. mcp_ticketer/cli/linear_commands.py +99 -15
  24. mcp_ticketer/cli/main.py +1199 -227
  25. mcp_ticketer/cli/mcp_configure.py +1 -1
  26. mcp_ticketer/cli/migrate_config.py +12 -8
  27. mcp_ticketer/cli/platform_commands.py +6 -6
  28. mcp_ticketer/cli/platform_detection.py +412 -0
  29. mcp_ticketer/cli/queue_commands.py +15 -15
  30. mcp_ticketer/cli/simple_health.py +1 -1
  31. mcp_ticketer/cli/ticket_commands.py +14 -13
  32. mcp_ticketer/cli/update_checker.py +313 -0
  33. mcp_ticketer/cli/utils.py +45 -41
  34. mcp_ticketer/core/__init__.py +12 -0
  35. mcp_ticketer/core/adapter.py +4 -4
  36. mcp_ticketer/core/config.py +17 -10
  37. mcp_ticketer/core/env_discovery.py +33 -3
  38. mcp_ticketer/core/env_loader.py +7 -6
  39. mcp_ticketer/core/exceptions.py +3 -3
  40. mcp_ticketer/core/http_client.py +10 -10
  41. mcp_ticketer/core/instructions.py +405 -0
  42. mcp_ticketer/core/mappers.py +1 -1
  43. mcp_ticketer/core/models.py +1 -1
  44. mcp_ticketer/core/onepassword_secrets.py +379 -0
  45. mcp_ticketer/core/project_config.py +17 -1
  46. mcp_ticketer/core/registry.py +1 -1
  47. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  48. mcp_ticketer/mcp/__init__.py +2 -2
  49. mcp_ticketer/mcp/server/__init__.py +2 -2
  50. mcp_ticketer/mcp/server/main.py +82 -69
  51. mcp_ticketer/mcp/server/tools/__init__.py +9 -0
  52. mcp_ticketer/mcp/server/tools/attachment_tools.py +63 -16
  53. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  54. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +154 -5
  55. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  56. mcp_ticketer/mcp/server/tools/ticket_tools.py +157 -4
  57. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  58. mcp_ticketer/queue/health_monitor.py +1 -0
  59. mcp_ticketer/queue/manager.py +4 -4
  60. mcp_ticketer/queue/queue.py +3 -3
  61. mcp_ticketer/queue/run_worker.py +1 -1
  62. mcp_ticketer/queue/ticket_registry.py +2 -2
  63. mcp_ticketer/queue/worker.py +14 -12
  64. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +106 -52
  65. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  66. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  67. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  68. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  69. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  70. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -13,7 +13,15 @@ from httpx import AsyncClient, HTTPStatusError, TimeoutException
13
13
 
14
14
  from ..core.adapter import BaseAdapter
15
15
  from ..core.env_loader import load_adapter_config, validate_adapter_config
16
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
16
+ from ..core.models import (
17
+ Attachment,
18
+ Comment,
19
+ Epic,
20
+ Priority,
21
+ SearchQuery,
22
+ Task,
23
+ TicketState,
24
+ )
17
25
  from ..core.registry import AdapterRegistry
18
26
 
19
27
  logger = logging.getLogger(__name__)
@@ -995,6 +1003,248 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
995
1003
  except Exception:
996
1004
  return None
997
1005
 
1006
+ async def list_labels(self) -> builtins.list[dict[str, Any]]:
1007
+ """List all labels used in the project.
1008
+
1009
+ JIRA doesn't have a direct "list all labels" endpoint, so we query
1010
+ recent issues and extract unique labels from them.
1011
+
1012
+ Returns:
1013
+ List of label dictionaries with 'id' and 'name' fields
1014
+
1015
+ """
1016
+ try:
1017
+ # Query recent issues to get labels in use
1018
+ jql = f"project = {self.project_key} ORDER BY updated DESC"
1019
+ data = await self._make_request(
1020
+ "GET",
1021
+ "search/jql",
1022
+ params={
1023
+ "jql": jql,
1024
+ "maxResults": 100, # Sample from recent 100 issues
1025
+ "fields": "labels",
1026
+ },
1027
+ )
1028
+
1029
+ # Collect unique labels
1030
+ unique_labels = set()
1031
+ for issue in data.get("issues", []):
1032
+ labels = issue.get("fields", {}).get("labels", [])
1033
+ for label in labels:
1034
+ if isinstance(label, dict):
1035
+ unique_labels.add(label.get("name", ""))
1036
+ else:
1037
+ unique_labels.add(str(label))
1038
+
1039
+ # Transform to standardized format
1040
+ return [
1041
+ {"id": label, "name": label} for label in sorted(unique_labels) if label
1042
+ ]
1043
+
1044
+ except Exception:
1045
+ # Fallback: return empty list if query fails
1046
+ return []
1047
+
1048
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1049
+ """Update a JIRA Epic with epic-specific field handling.
1050
+
1051
+ Args:
1052
+ epic_id: Epic identifier (key like PROJ-123 or ID)
1053
+ updates: Dictionary with fields to update:
1054
+ - title: Epic title (maps to summary)
1055
+ - description: Epic description (auto-converted to ADF)
1056
+ - state: TicketState value (transitions via workflow)
1057
+ - tags: List of labels
1058
+ - priority: Priority level
1059
+
1060
+ Returns:
1061
+ Updated Epic object or None if not found
1062
+
1063
+ Raises:
1064
+ ValueError: If no fields provided for update
1065
+ HTTPStatusError: If update fails
1066
+
1067
+ """
1068
+ fields = {}
1069
+
1070
+ # Map title to summary
1071
+ if "title" in updates:
1072
+ fields["summary"] = updates["title"]
1073
+
1074
+ # Convert description to ADF format
1075
+ if "description" in updates:
1076
+ fields["description"] = self._convert_to_adf(updates["description"])
1077
+
1078
+ # Map tags to labels
1079
+ if "tags" in updates:
1080
+ fields["labels"] = updates["tags"]
1081
+
1082
+ # Map priority (some JIRA configs allow priority on Epics)
1083
+ if "priority" in updates:
1084
+ priority_value = updates["priority"]
1085
+ if isinstance(priority_value, Priority):
1086
+ fields["priority"] = {
1087
+ "name": self._map_priority_to_jira(priority_value)
1088
+ }
1089
+ else:
1090
+ # String priority passed directly
1091
+ fields["priority"] = {"name": priority_value}
1092
+
1093
+ if not fields and "state" not in updates:
1094
+ raise ValueError("At least one field must be updated")
1095
+
1096
+ # Apply field updates if any
1097
+ if fields:
1098
+ await self._make_request("PUT", f"issue/{epic_id}", data={"fields": fields})
1099
+
1100
+ # Handle state transitions separately (JIRA uses workflow transitions)
1101
+ if "state" in updates:
1102
+ await self.transition_state(epic_id, updates["state"])
1103
+
1104
+ # Fetch and return updated epic
1105
+ return await self.read(epic_id)
1106
+
1107
+ async def add_attachment(
1108
+ self, ticket_id: str, file_path: str, description: str | None = None
1109
+ ) -> Attachment:
1110
+ """Attach file to JIRA issue (including Epic).
1111
+
1112
+ Args:
1113
+ ticket_id: Issue key (e.g., PROJ-123) or ID
1114
+ file_path: Path to file to attach
1115
+ description: Optional description (stored in metadata, not used by JIRA directly)
1116
+
1117
+ Returns:
1118
+ Attachment object with metadata
1119
+
1120
+ Raises:
1121
+ FileNotFoundError: If file doesn't exist
1122
+ ValueError: If credentials invalid
1123
+ HTTPStatusError: If upload fails
1124
+
1125
+ """
1126
+ from pathlib import Path
1127
+
1128
+ # Validate credentials before attempting operation
1129
+ is_valid, error_message = self.validate_credentials()
1130
+ if not is_valid:
1131
+ raise ValueError(error_message)
1132
+
1133
+ file_path_obj = Path(file_path)
1134
+ if not file_path_obj.exists():
1135
+ raise FileNotFoundError(f"File not found: {file_path}")
1136
+
1137
+ # JIRA requires special header for attachment upload
1138
+ headers = {
1139
+ "X-Atlassian-Token": "no-check",
1140
+ # Don't set Content-Type - let httpx handle multipart
1141
+ }
1142
+
1143
+ # Prepare multipart file upload
1144
+ with open(file_path_obj, "rb") as f:
1145
+ files = {"file": (file_path_obj.name, f, "application/octet-stream")}
1146
+
1147
+ url = f"{self.api_base}/issue/{ticket_id}/attachments"
1148
+
1149
+ # Use existing client infrastructure
1150
+ async with await self._get_client() as client:
1151
+ response = await client.post(
1152
+ url, files=files, headers={**self.headers, **headers}
1153
+ )
1154
+ response.raise_for_status()
1155
+
1156
+ # JIRA returns array with single attachment
1157
+ attachment_data = response.json()[0]
1158
+
1159
+ return Attachment(
1160
+ id=attachment_data["id"],
1161
+ ticket_id=ticket_id,
1162
+ filename=attachment_data["filename"],
1163
+ url=attachment_data["content"],
1164
+ content_type=attachment_data["mimeType"],
1165
+ size_bytes=attachment_data["size"],
1166
+ created_at=parse_jira_datetime(attachment_data["created"]),
1167
+ created_by=attachment_data["author"]["displayName"],
1168
+ description=description,
1169
+ metadata={"jira": attachment_data},
1170
+ )
1171
+
1172
+ async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
1173
+ """Get all attachments for a JIRA issue.
1174
+
1175
+ Args:
1176
+ ticket_id: Issue key or ID
1177
+
1178
+ Returns:
1179
+ List of Attachment objects
1180
+
1181
+ Raises:
1182
+ ValueError: If credentials invalid
1183
+ HTTPStatusError: If request fails
1184
+
1185
+ """
1186
+ # Validate credentials before attempting operation
1187
+ is_valid, error_message = self.validate_credentials()
1188
+ if not is_valid:
1189
+ raise ValueError(error_message)
1190
+
1191
+ # Fetch issue with attachment field
1192
+ issue = await self._make_request(
1193
+ "GET", f"issue/{ticket_id}", params={"fields": "attachment"}
1194
+ )
1195
+
1196
+ attachments = []
1197
+ for att_data in issue.get("fields", {}).get("attachment", []):
1198
+ attachments.append(
1199
+ Attachment(
1200
+ id=att_data["id"],
1201
+ ticket_id=ticket_id,
1202
+ filename=att_data["filename"],
1203
+ url=att_data["content"],
1204
+ content_type=att_data["mimeType"],
1205
+ size_bytes=att_data["size"],
1206
+ created_at=parse_jira_datetime(att_data["created"]),
1207
+ created_by=att_data["author"]["displayName"],
1208
+ metadata={"jira": att_data},
1209
+ )
1210
+ )
1211
+
1212
+ return attachments
1213
+
1214
+ async def delete_attachment(self, ticket_id: str, attachment_id: str) -> bool:
1215
+ """Delete an attachment from a JIRA issue.
1216
+
1217
+ Args:
1218
+ ticket_id: Issue key or ID (for validation/context)
1219
+ attachment_id: Attachment ID to delete
1220
+
1221
+ Returns:
1222
+ True if deleted successfully, False otherwise
1223
+
1224
+ Raises:
1225
+ ValueError: If credentials invalid
1226
+
1227
+ """
1228
+ # Validate credentials before attempting operation
1229
+ is_valid, error_message = self.validate_credentials()
1230
+ if not is_valid:
1231
+ raise ValueError(error_message)
1232
+
1233
+ try:
1234
+ await self._make_request("DELETE", f"attachment/{attachment_id}")
1235
+ return True
1236
+ except HTTPStatusError as e:
1237
+ if e.response.status_code == 404:
1238
+ logger.warning(f"Attachment {attachment_id} not found")
1239
+ return False
1240
+ logger.error(
1241
+ f"Failed to delete attachment {attachment_id}: {e.response.status_code} - {e.response.text}"
1242
+ )
1243
+ return False
1244
+ except Exception as e:
1245
+ logger.error(f"Unexpected error deleting attachment {attachment_id}: {e}")
1246
+ return False
1247
+
998
1248
  async def close(self) -> None:
999
1249
  """Close the adapter and cleanup resources."""
1000
1250
  # Clear caches