mcp-ticketer 0.3.5__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 (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  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 +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -3,7 +3,8 @@
3
3
  import builtins
4
4
  import re
5
5
  from datetime import datetime
6
- from typing import Any, Optional
6
+ from pathlib import Path
7
+ from typing import Any
7
8
 
8
9
  import httpx
9
10
 
@@ -141,11 +142,11 @@ class GitHubAdapter(BaseAdapter[Task]):
141
142
 
142
143
  Args:
143
144
  config: Configuration with:
144
- - token: GitHub Personal Access Token (or GITHUB_TOKEN env var)
145
+ - token: GitHub PAT (or GITHUB_TOKEN env var)
145
146
  - owner: Repository owner (or GITHUB_OWNER env var)
146
147
  - repo: Repository name (or GITHUB_REPO env var)
147
- - api_url: Optional API URL for GitHub Enterprise (defaults to github.com)
148
- - use_projects_v2: Enable GitHub Projects v2 integration (default: False)
148
+ - api_url: Optional API URL for GitHub Enterprise
149
+ - use_projects_v2: Enable Projects v2 (default: False)
149
150
  - custom_priority_scheme: Custom priority label mapping
150
151
 
151
152
  """
@@ -157,11 +158,12 @@ class GitHubAdapter(BaseAdapter[Task]):
157
158
  # Validate required configuration
158
159
  missing_keys = validate_adapter_config("github", full_config)
159
160
  if missing_keys:
161
+ missing = ", ".join(missing_keys)
160
162
  raise ValueError(
161
- f"GitHub adapter missing required configuration: {', '.join(missing_keys)}"
163
+ f"GitHub adapter missing required configuration: {missing}"
162
164
  )
163
165
 
164
- # Get authentication token - support both 'api_key' and 'token' for compatibility
166
+ # Get authentication token - support 'api_key' and 'token'
165
167
  self.token = (
166
168
  full_config.get("api_key")
167
169
  or full_config.get("token")
@@ -198,8 +200,8 @@ class GitHubAdapter(BaseAdapter[Task]):
198
200
  )
199
201
 
200
202
  # Cache for labels and milestones
201
- self._labels_cache: Optional[list[dict[str, Any]]] = None
202
- self._milestones_cache: Optional[list[dict[str, Any]]] = None
203
+ self._labels_cache: list[dict[str, Any]] | None = None
204
+ self._milestones_cache: list[dict[str, Any]] | None = None
203
205
  self._rate_limit: dict[str, Any] = {}
204
206
 
205
207
  def validate_credentials(self) -> tuple[bool, str]:
@@ -212,17 +214,21 @@ class GitHubAdapter(BaseAdapter[Task]):
212
214
  if not self.token:
213
215
  return (
214
216
  False,
215
- "GITHUB_TOKEN is required but not found. Set it in .env.local or environment.",
217
+ "GITHUB_TOKEN is required. Set it in .env.local or environment.",
216
218
  )
217
219
  if not self.owner:
218
220
  return (
219
221
  False,
220
- "GitHub owner is required in configuration. Set GITHUB_OWNER in .env.local or configure with 'mcp-ticketer init --adapter github --github-owner <owner>'",
222
+ "GitHub owner is required. Set GITHUB_OWNER in .env.local "
223
+ "or configure with 'mcp-ticketer init --adapter github "
224
+ "--github-owner <owner>'",
221
225
  )
222
226
  if not self.repo:
223
227
  return (
224
228
  False,
225
- "GitHub repo is required in configuration. Set GITHUB_REPO in .env.local or configure with 'mcp-ticketer init --adapter github --github-repo <repo>'",
229
+ "GitHub repo is required. Set GITHUB_REPO in .env.local "
230
+ "or configure with 'mcp-ticketer init --adapter github "
231
+ "--github-repo <repo>'",
226
232
  )
227
233
  return True, ""
228
234
 
@@ -239,7 +245,7 @@ class GitHubAdapter(BaseAdapter[Task]):
239
245
  TicketState.CLOSED: GitHubStateMapping.CLOSED,
240
246
  }
241
247
 
242
- def _get_state_label(self, state: TicketState) -> Optional[str]:
248
+ def _get_state_label(self, state: TicketState) -> str | None:
243
249
  """Get the label name for extended states."""
244
250
  return GitHubStateMapping.STATE_LABELS.get(state)
245
251
 
@@ -278,6 +284,39 @@ class GitHubAdapter(BaseAdapter[Task]):
278
284
  else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
279
285
  )
280
286
 
287
+ def _milestone_to_epic(self, milestone: dict[str, Any]) -> Epic:
288
+ """Convert GitHub milestone to Epic model.
289
+
290
+ Args:
291
+ milestone: GitHub milestone data
292
+
293
+ Returns:
294
+ Epic instance
295
+
296
+ """
297
+ return Epic(
298
+ id=str(milestone["number"]),
299
+ title=milestone["title"],
300
+ description=milestone.get("description", ""),
301
+ state=(
302
+ TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
303
+ ),
304
+ created_at=datetime.fromisoformat(
305
+ milestone["created_at"].replace("Z", "+00:00")
306
+ ),
307
+ updated_at=datetime.fromisoformat(
308
+ milestone["updated_at"].replace("Z", "+00:00")
309
+ ),
310
+ metadata={
311
+ "github": {
312
+ "number": milestone["number"],
313
+ "url": milestone.get("html_url"),
314
+ "open_issues": milestone.get("open_issues", 0),
315
+ "closed_issues": milestone.get("closed_issues", 0),
316
+ }
317
+ },
318
+ )
319
+
281
320
  def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
282
321
  """Extract ticket state from GitHub issue data."""
283
322
  # Check if closed
@@ -508,7 +547,7 @@ class GitHubAdapter(BaseAdapter[Task]):
508
547
 
509
548
  return self._task_from_github_issue(created_issue)
510
549
 
511
- async def read(self, ticket_id: str) -> Optional[Task]:
550
+ async def read(self, ticket_id: str) -> Task | None:
512
551
  """Read a GitHub issue by number."""
513
552
  # Validate credentials before attempting operation
514
553
  is_valid, error_message = self.validate_credentials()
@@ -533,7 +572,7 @@ class GitHubAdapter(BaseAdapter[Task]):
533
572
  except httpx.HTTPError:
534
573
  return None
535
574
 
536
- async def update(self, ticket_id: str, updates: dict[str, Any]) -> Optional[Task]:
575
+ async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
537
576
  """Update a GitHub issue."""
538
577
  # Validate credentials before attempting operation
539
578
  is_valid, error_message = self.validate_credentials()
@@ -685,11 +724,11 @@ class GitHubAdapter(BaseAdapter[Task]):
685
724
  return False
686
725
 
687
726
  async def list(
688
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
727
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
689
728
  ) -> list[Task]:
690
729
  """List GitHub issues with filters."""
691
730
  # Build query parameters
692
- params = {
731
+ params: dict[str, Any] = {
693
732
  "per_page": min(limit, 100), # GitHub max is 100
694
733
  "page": (offset // limit) + 1 if limit > 0 else 1,
695
734
  }
@@ -837,7 +876,7 @@ class GitHubAdapter(BaseAdapter[Task]):
837
876
 
838
877
  async def transition_state(
839
878
  self, ticket_id: str, target_state: TicketState
840
- ) -> Optional[Task]:
879
+ ) -> Task | None:
841
880
  """Transition GitHub issue to a new state."""
842
881
  # Validate transition
843
882
  if not await self.validate_transition(ticket_id, target_state):
@@ -850,8 +889,8 @@ class GitHubAdapter(BaseAdapter[Task]):
850
889
  """Add a comment to a GitHub issue."""
851
890
  try:
852
891
  issue_number = int(comment.ticket_id)
853
- except ValueError:
854
- raise ValueError(f"Invalid issue number: {comment.ticket_id}")
892
+ except ValueError as e:
893
+ raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e
855
894
 
856
895
  # Create comment
857
896
  response = await self.client.post(
@@ -945,33 +984,9 @@ class GitHubAdapter(BaseAdapter[Task]):
945
984
  response.raise_for_status()
946
985
 
947
986
  created_milestone = response.json()
987
+ return self._milestone_to_epic(created_milestone)
948
988
 
949
- return Epic(
950
- id=str(created_milestone["number"]),
951
- title=created_milestone["title"],
952
- description=created_milestone["description"],
953
- state=(
954
- TicketState.OPEN
955
- if created_milestone["state"] == "open"
956
- else TicketState.CLOSED
957
- ),
958
- created_at=datetime.fromisoformat(
959
- created_milestone["created_at"].replace("Z", "+00:00")
960
- ),
961
- updated_at=datetime.fromisoformat(
962
- created_milestone["updated_at"].replace("Z", "+00:00")
963
- ),
964
- metadata={
965
- "github": {
966
- "number": created_milestone["number"],
967
- "url": created_milestone["html_url"],
968
- "open_issues": created_milestone["open_issues"],
969
- "closed_issues": created_milestone["closed_issues"],
970
- }
971
- },
972
- )
973
-
974
- async def get_milestone(self, milestone_number: int) -> Optional[Epic]:
989
+ async def get_milestone(self, milestone_number: int) -> Epic | None:
975
990
  """Get a GitHub milestone as an Epic."""
976
991
  try:
977
992
  response = await self.client.get(
@@ -982,31 +997,7 @@ class GitHubAdapter(BaseAdapter[Task]):
982
997
  response.raise_for_status()
983
998
 
984
999
  milestone = response.json()
985
-
986
- return Epic(
987
- id=str(milestone["number"]),
988
- title=milestone["title"],
989
- description=milestone["description"],
990
- state=(
991
- TicketState.OPEN
992
- if milestone["state"] == "open"
993
- else TicketState.CLOSED
994
- ),
995
- created_at=datetime.fromisoformat(
996
- milestone["created_at"].replace("Z", "+00:00")
997
- ),
998
- updated_at=datetime.fromisoformat(
999
- milestone["updated_at"].replace("Z", "+00:00")
1000
- ),
1001
- metadata={
1002
- "github": {
1003
- "number": milestone["number"],
1004
- "url": milestone["html_url"],
1005
- "open_issues": milestone["open_issues"],
1006
- "closed_issues": milestone["closed_issues"],
1007
- }
1008
- },
1009
- )
1000
+ return self._milestone_to_epic(milestone)
1010
1001
  except httpx.HTTPError:
1011
1002
  return None
1012
1003
 
@@ -1025,36 +1016,7 @@ class GitHubAdapter(BaseAdapter[Task]):
1025
1016
  )
1026
1017
  response.raise_for_status()
1027
1018
 
1028
- epics = []
1029
- for milestone in response.json():
1030
- epics.append(
1031
- Epic(
1032
- id=str(milestone["number"]),
1033
- title=milestone["title"],
1034
- description=milestone["description"],
1035
- state=(
1036
- TicketState.OPEN
1037
- if milestone["state"] == "open"
1038
- else TicketState.CLOSED
1039
- ),
1040
- created_at=datetime.fromisoformat(
1041
- milestone["created_at"].replace("Z", "+00:00")
1042
- ),
1043
- updated_at=datetime.fromisoformat(
1044
- milestone["updated_at"].replace("Z", "+00:00")
1045
- ),
1046
- metadata={
1047
- "github": {
1048
- "number": milestone["number"],
1049
- "url": milestone["html_url"],
1050
- "open_issues": milestone["open_issues"],
1051
- "closed_issues": milestone["closed_issues"],
1052
- }
1053
- },
1054
- )
1055
- )
1056
-
1057
- return epics
1019
+ return [self._milestone_to_epic(milestone) for milestone in response.json()]
1058
1020
 
1059
1021
  async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
1060
1022
  """Link an issue to a pull request using keywords."""
@@ -1073,9 +1035,9 @@ class GitHubAdapter(BaseAdapter[Task]):
1073
1035
  self,
1074
1036
  ticket_id: str,
1075
1037
  base_branch: str = "main",
1076
- head_branch: Optional[str] = None,
1077
- title: Optional[str] = None,
1078
- body: Optional[str] = None,
1038
+ head_branch: str | None = None,
1039
+ title: str | None = None,
1040
+ body: str | None = None,
1079
1041
  draft: bool = False,
1080
1042
  ) -> dict[str, Any]:
1081
1043
  """Create a pull request linked to an issue.
@@ -1094,8 +1056,8 @@ class GitHubAdapter(BaseAdapter[Task]):
1094
1056
  """
1095
1057
  try:
1096
1058
  issue_number = int(ticket_id)
1097
- except ValueError:
1098
- raise ValueError(f"Invalid issue number: {ticket_id}")
1059
+ except ValueError as e:
1060
+ raise ValueError(f"Invalid issue number: {ticket_id}") from e
1099
1061
 
1100
1062
  # Get the issue details
1101
1063
  issue = await self.read(ticket_id)
@@ -1229,10 +1191,11 @@ Fixes #{issue_number}
1229
1191
  pr = pr_response.json()
1230
1192
 
1231
1193
  # Add a comment to the issue about the PR
1194
+ pr_msg = f"Pull request #{pr['number']} has been created: " f"{pr['html_url']}"
1232
1195
  await self.add_comment(
1233
1196
  Comment(
1234
1197
  ticket_id=ticket_id,
1235
- content=f"Pull request #{pr['number']} has been created: {pr['html_url']}",
1198
+ content=pr_msg,
1236
1199
  author="system",
1237
1200
  )
1238
1201
  )
@@ -1265,8 +1228,8 @@ Fixes #{issue_number}
1265
1228
  """
1266
1229
  try:
1267
1230
  issue_number = int(ticket_id)
1268
- except ValueError:
1269
- raise ValueError(f"Invalid issue number: {ticket_id}")
1231
+ except ValueError as e:
1232
+ raise ValueError(f"Invalid issue number: {ticket_id}") from e
1270
1233
 
1271
1234
  # Parse PR URL to extract owner, repo, and PR number
1272
1235
  # Expected format: https://github.com/owner/repo/pull/123
@@ -1342,12 +1305,266 @@ Fixes #{issue_number}
1342
1305
  response.raise_for_status()
1343
1306
  return response.json()
1344
1307
 
1345
- async def get_current_user(self) -> Optional[dict[str, Any]]:
1308
+ async def get_current_user(self) -> dict[str, Any] | None:
1346
1309
  """Get current authenticated user information."""
1347
1310
  response = await self.client.get("/user")
1348
1311
  response.raise_for_status()
1349
1312
  return response.json()
1350
1313
 
1314
+ async def list_labels(self) -> builtins.list[dict[str, Any]]:
1315
+ """List all labels available in the repository.
1316
+
1317
+ Returns:
1318
+ List of label dictionaries with 'id', 'name', and 'color' fields
1319
+
1320
+ """
1321
+ if self._labels_cache:
1322
+ return self._labels_cache
1323
+
1324
+ response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
1325
+ response.raise_for_status()
1326
+ labels = response.json()
1327
+
1328
+ # Transform to standardized format
1329
+ standardized_labels = [
1330
+ {"id": label["name"], "name": label["name"], "color": label["color"]}
1331
+ for label in labels
1332
+ ]
1333
+
1334
+ self._labels_cache = standardized_labels
1335
+ return standardized_labels
1336
+
1337
+ async def update_milestone(
1338
+ self, milestone_number: int, updates: dict[str, Any]
1339
+ ) -> Epic | None:
1340
+ """Update a GitHub milestone (Epic).
1341
+
1342
+ Args:
1343
+ milestone_number: Milestone number (not ID)
1344
+ updates: Dictionary with fields to update:
1345
+ - title: Milestone title
1346
+ - description: Milestone description (supports markdown)
1347
+ - state: TicketState value (maps to open/closed)
1348
+ - target_date: Due date in ISO format
1349
+
1350
+ Returns:
1351
+ Updated Epic object or None if not found
1352
+
1353
+ Raises:
1354
+ ValueError: If no fields to update
1355
+ httpx.HTTPError: If API request fails
1356
+
1357
+ """
1358
+ update_data = {}
1359
+
1360
+ # Map title directly
1361
+ if "title" in updates:
1362
+ update_data["title"] = updates["title"]
1363
+
1364
+ # Map description (supports markdown)
1365
+ if "description" in updates:
1366
+ update_data["description"] = updates["description"]
1367
+
1368
+ # Map state to GitHub milestone state
1369
+ if "state" in updates:
1370
+ state = updates["state"]
1371
+ if isinstance(state, TicketState):
1372
+ # GitHub only has open/closed
1373
+ update_data["state"] = (
1374
+ "closed"
1375
+ if state in [TicketState.DONE, TicketState.CLOSED]
1376
+ else "open"
1377
+ )
1378
+ else:
1379
+ update_data["state"] = state
1380
+
1381
+ # Map target_date to due_on
1382
+ if "target_date" in updates:
1383
+ # GitHub expects ISO 8601 format
1384
+ target_date = updates["target_date"]
1385
+ if isinstance(target_date, str):
1386
+ update_data["due_on"] = target_date
1387
+ elif hasattr(target_date, "isoformat"):
1388
+ update_data["due_on"] = target_date.isoformat()
1389
+
1390
+ if not update_data:
1391
+ raise ValueError("At least one field must be updated")
1392
+
1393
+ # Make API request
1394
+ response = await self.client.patch(
1395
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}",
1396
+ json=update_data,
1397
+ )
1398
+ response.raise_for_status()
1399
+
1400
+ # Convert response to Epic
1401
+ milestone_data = response.json()
1402
+ return self._milestone_to_epic(milestone_data)
1403
+
1404
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1405
+ """Update a GitHub epic (milestone) by ID or number.
1406
+
1407
+ This is a convenience wrapper around update_milestone() that accepts
1408
+ either a milestone number or the epic ID from the Epic object.
1409
+
1410
+ Args:
1411
+ epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
1412
+ updates: Dictionary with fields to update
1413
+
1414
+ Returns:
1415
+ Updated Epic object or None if not found
1416
+
1417
+ """
1418
+ # Extract milestone number from ID
1419
+ if epic_id.startswith("milestone-"):
1420
+ milestone_number = int(epic_id.replace("milestone-", ""))
1421
+ else:
1422
+ milestone_number = int(epic_id)
1423
+
1424
+ return await self.update_milestone(milestone_number, updates)
1425
+
1426
+ async def add_attachment_to_issue(
1427
+ self, issue_number: int, file_path: str, comment: str | None = None
1428
+ ) -> dict[str, Any]:
1429
+ """Attach file to GitHub issue via comment.
1430
+
1431
+ GitHub doesn't have direct file attachment API. This method:
1432
+ 1. Creates a comment with the file reference
1433
+ 2. Returns metadata about the attachment
1434
+
1435
+ Note: GitHub's actual file upload in comments requires browser-based
1436
+ drag-and-drop or git-lfs. This method creates a placeholder comment
1437
+ that users can edit to add actual file attachments through the UI.
1438
+
1439
+ Args:
1440
+ issue_number: Issue number
1441
+ file_path: Path to file to attach
1442
+ comment: Optional comment text (defaults to "Attached: {filename}")
1443
+
1444
+ Returns:
1445
+ Dictionary with comment data and file info
1446
+
1447
+ Raises:
1448
+ FileNotFoundError: If file doesn't exist
1449
+ ValueError: If file too large (>25 MB)
1450
+
1451
+ Note:
1452
+ GitHub file size limit: 25 MB
1453
+ Supported: Images, videos, documents
1454
+
1455
+ """
1456
+ file_path_obj = Path(file_path)
1457
+ if not file_path_obj.exists():
1458
+ raise FileNotFoundError(f"File not found: {file_path}")
1459
+
1460
+ # Check file size (25 MB limit)
1461
+ file_size = file_path_obj.stat().st_size
1462
+ if file_size > 25 * 1024 * 1024: # 25 MB
1463
+ raise ValueError(
1464
+ f"File too large: {file_size} bytes (max 25 MB). "
1465
+ "Upload file externally and reference URL instead."
1466
+ )
1467
+
1468
+ # Prepare comment body
1469
+ comment_body = comment or f"📎 Attached: `{file_path_obj.name}`"
1470
+ comment_body += (
1471
+ f"\n\n*Note: File `{file_path_obj.name}` ({file_size} bytes) "
1472
+ "needs to be manually uploaded through GitHub UI or referenced via URL.*"
1473
+ )
1474
+
1475
+ # Create comment with file reference
1476
+ response = await self.client.post(
1477
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
1478
+ json={"body": comment_body},
1479
+ )
1480
+ response.raise_for_status()
1481
+
1482
+ comment_data = response.json()
1483
+
1484
+ return {
1485
+ "comment_id": comment_data["id"],
1486
+ "comment_url": comment_data["html_url"],
1487
+ "filename": file_path_obj.name,
1488
+ "file_size": file_size,
1489
+ "note": "File reference created. Upload file manually through GitHub UI.",
1490
+ }
1491
+
1492
+ async def add_attachment_reference_to_milestone(
1493
+ self, milestone_number: int, file_url: str, description: str
1494
+ ) -> Epic | None:
1495
+ """Add file reference to milestone description.
1496
+
1497
+ Since GitHub milestones don't support direct file attachments,
1498
+ this method appends a markdown link to the milestone description.
1499
+
1500
+ Args:
1501
+ milestone_number: Milestone number
1502
+ file_url: URL to the file (external or GitHub-hosted)
1503
+ description: Description/title for the file
1504
+
1505
+ Returns:
1506
+ Updated Epic object
1507
+
1508
+ Example:
1509
+ await adapter.add_attachment_reference_to_milestone(
1510
+ 5,
1511
+ "https://example.com/spec.pdf",
1512
+ "Technical Specification"
1513
+ )
1514
+ # Appends to description: "[Technical Specification](https://example.com/spec.pdf)"
1515
+
1516
+ """
1517
+ # Get current milestone
1518
+ response = await self.client.get(
1519
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
1520
+ )
1521
+ response.raise_for_status()
1522
+ milestone = response.json()
1523
+
1524
+ # Append file reference to description
1525
+ current_desc = milestone.get("description", "")
1526
+ attachment_markdown = f"\n\n📎 [{description}]({file_url})"
1527
+ new_description = current_desc + attachment_markdown
1528
+
1529
+ # Update milestone with new description
1530
+ return await self.update_milestone(
1531
+ milestone_number, {"description": new_description}
1532
+ )
1533
+
1534
+ async def add_attachment(
1535
+ self, ticket_id: str, file_path: str, description: str | None = None
1536
+ ) -> dict[str, Any]:
1537
+ """Add attachment to GitHub ticket (issue or milestone).
1538
+
1539
+ This method routes to appropriate attachment method based on ticket type:
1540
+ - Issues: Creates comment with file reference
1541
+ - Milestones: Not supported, raises NotImplementedError with guidance
1542
+
1543
+ Args:
1544
+ ticket_id: Ticket identifier (issue number or milestone ID)
1545
+ file_path: Path to file to attach
1546
+ description: Optional description
1547
+
1548
+ Returns:
1549
+ Attachment metadata
1550
+
1551
+ Raises:
1552
+ NotImplementedError: For milestones (no native support)
1553
+ FileNotFoundError: If file doesn't exist
1554
+
1555
+ """
1556
+ # Determine ticket type from ID format
1557
+ if ticket_id.startswith("milestone-"):
1558
+ raise NotImplementedError(
1559
+ "GitHub milestones do not support direct file attachments. "
1560
+ "Workaround: Upload file externally and use "
1561
+ "add_attachment_reference_to_milestone() to add URL to description."
1562
+ )
1563
+
1564
+ # Assume it's an issue number
1565
+ issue_number = int(ticket_id.replace("issue-", ""))
1566
+ return await self.add_attachment_to_issue(issue_number, file_path, description)
1567
+
1351
1568
  async def close(self) -> None:
1352
1569
  """Close the HTTP client connection."""
1353
1570
  await self.client.aclose()
@@ -8,7 +8,7 @@ import builtins
8
8
  import json
9
9
  import logging
10
10
  from pathlib import Path
11
- from typing import Any, Optional, Union
11
+ from typing import Any
12
12
 
13
13
  from ..core.adapter import BaseAdapter
14
14
  from ..core.models import Comment, Epic, SearchQuery, Task, TicketState
@@ -129,7 +129,7 @@ class HybridAdapter(BaseAdapter):
129
129
 
130
130
  def _get_adapter_ticket_id(
131
131
  self, universal_id: str, adapter_name: str
132
- ) -> Optional[str]:
132
+ ) -> str | None:
133
133
  """Get adapter-specific ticket ID from universal ID.
134
134
 
135
135
  Args:
@@ -153,7 +153,7 @@ class HybridAdapter(BaseAdapter):
153
153
 
154
154
  return f"hybrid-{uuid.uuid4().hex[:12]}"
155
155
 
156
- async def create(self, ticket: Union[Task, Epic]) -> Union[Task, Epic]:
156
+ async def create(self, ticket: Task | Epic) -> Task | Epic:
157
157
  """Create ticket in all configured adapters.
158
158
 
159
159
  Args:
@@ -208,7 +208,7 @@ class HybridAdapter(BaseAdapter):
208
208
  return primary_ticket
209
209
 
210
210
  def _add_cross_references(
211
- self, ticket: Union[Task, Epic], results: list[tuple[str, Union[Task, Epic]]]
211
+ self, ticket: Task | Epic, results: list[tuple[str, Task | Epic]]
212
212
  ) -> None:
213
213
  """Add cross-references to ticket description.
214
214
 
@@ -226,7 +226,7 @@ class HybridAdapter(BaseAdapter):
226
226
  else:
227
227
  ticket.description = cross_refs.strip()
228
228
 
229
- async def read(self, ticket_id: str) -> Optional[Union[Task, Epic]]:
229
+ async def read(self, ticket_id: str) -> Task | Epic | None:
230
230
  """Read ticket from primary adapter.
231
231
 
232
232
  Args:
@@ -255,7 +255,7 @@ class HybridAdapter(BaseAdapter):
255
255
 
256
256
  async def update(
257
257
  self, ticket_id: str, updates: dict[str, Any]
258
- ) -> Optional[Union[Task, Epic]]:
258
+ ) -> Task | Epic | None:
259
259
  """Update ticket across all adapters.
260
260
 
261
261
  Args:
@@ -300,7 +300,7 @@ class HybridAdapter(BaseAdapter):
300
300
 
301
301
  return None
302
302
 
303
- def _find_universal_id(self, adapter_ticket_id: str) -> Optional[str]:
303
+ def _find_universal_id(self, adapter_ticket_id: str) -> str | None:
304
304
  """Find universal ID for an adapter-specific ticket ID.
305
305
 
306
306
  Args:
@@ -359,8 +359,8 @@ class HybridAdapter(BaseAdapter):
359
359
  return success_count > 0
360
360
 
361
361
  async def list(
362
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
363
- ) -> list[Union[Task, Epic]]:
362
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
363
+ ) -> list[Task | Epic]:
364
364
  """List tickets from primary adapter.
365
365
 
366
366
  Args:
@@ -375,7 +375,7 @@ class HybridAdapter(BaseAdapter):
375
375
  primary = self.adapters[self.primary_adapter_name]
376
376
  return await primary.list(limit, offset, filters)
377
377
 
378
- async def search(self, query: SearchQuery) -> builtins.list[Union[Task, Epic]]:
378
+ async def search(self, query: SearchQuery) -> builtins.list[Task | Epic]:
379
379
  """Search tickets in primary adapter.
380
380
 
381
381
  Args:
@@ -390,7 +390,7 @@ class HybridAdapter(BaseAdapter):
390
390
 
391
391
  async def transition_state(
392
392
  self, ticket_id: str, target_state: TicketState
393
- ) -> Optional[Union[Task, Epic]]:
393
+ ) -> Task | Epic | None:
394
394
  """Transition ticket state across all adapters.
395
395
 
396
396
  Args: