mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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 (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,1574 +0,0 @@
1
- """GitHub adapter implementation using REST API v3 and GraphQL API v4."""
2
-
3
- import builtins
4
- import re
5
- from datetime import datetime
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- import httpx
10
-
11
- from ..core.adapter import BaseAdapter
12
- from ..core.env_loader import load_adapter_config, validate_adapter_config
13
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
14
- from ..core.registry import AdapterRegistry
15
-
16
-
17
- class GitHubStateMapping:
18
- """GitHub issue states and label-based extended states."""
19
-
20
- # GitHub native states
21
- OPEN = "open"
22
- CLOSED = "closed"
23
-
24
- # Extended states via labels
25
- STATE_LABELS = {
26
- TicketState.IN_PROGRESS: "in-progress",
27
- TicketState.READY: "ready",
28
- TicketState.TESTED: "tested",
29
- TicketState.WAITING: "waiting",
30
- TicketState.BLOCKED: "blocked",
31
- }
32
-
33
- # Priority labels
34
- PRIORITY_LABELS = {
35
- Priority.CRITICAL: ["P0", "critical", "urgent"],
36
- Priority.HIGH: ["P1", "high"],
37
- Priority.MEDIUM: ["P2", "medium"],
38
- Priority.LOW: ["P3", "low"],
39
- }
40
-
41
-
42
- class GitHubGraphQLQueries:
43
- """GraphQL queries for GitHub API v4."""
44
-
45
- ISSUE_FRAGMENT = """
46
- fragment IssueFields on Issue {
47
- id
48
- number
49
- title
50
- body
51
- state
52
- createdAt
53
- updatedAt
54
- url
55
- author {
56
- login
57
- }
58
- assignees(first: 10) {
59
- nodes {
60
- login
61
- email
62
- }
63
- }
64
- labels(first: 20) {
65
- nodes {
66
- name
67
- color
68
- }
69
- }
70
- milestone {
71
- id
72
- number
73
- title
74
- state
75
- description
76
- }
77
- projectCards(first: 10) {
78
- nodes {
79
- project {
80
- name
81
- url
82
- }
83
- column {
84
- name
85
- }
86
- }
87
- }
88
- comments(first: 100) {
89
- nodes {
90
- id
91
- body
92
- author {
93
- login
94
- }
95
- createdAt
96
- }
97
- }
98
- reactions(first: 10) {
99
- nodes {
100
- content
101
- user {
102
- login
103
- }
104
- }
105
- }
106
- }
107
- """
108
-
109
- GET_ISSUE = """
110
- query GetIssue($owner: String!, $repo: String!, $number: Int!) {
111
- repository(owner: $owner, name: $repo) {
112
- issue(number: $number) {
113
- ...IssueFields
114
- }
115
- }
116
- }
117
- """
118
-
119
- SEARCH_ISSUES = """
120
- query SearchIssues($query: String!, $first: Int!, $after: String) {
121
- search(query: $query, type: ISSUE, first: $first, after: $after) {
122
- issueCount
123
- pageInfo {
124
- hasNextPage
125
- endCursor
126
- }
127
- nodes {
128
- ... on Issue {
129
- ...IssueFields
130
- }
131
- }
132
- }
133
- }
134
- """
135
-
136
-
137
- class GitHubAdapter(BaseAdapter[Task]):
138
- """Adapter for GitHub Issues tracking system."""
139
-
140
- def __init__(self, config: dict[str, Any]):
141
- """Initialize GitHub adapter.
142
-
143
- Args:
144
- config: Configuration with:
145
- - token: GitHub PAT (or GITHUB_TOKEN env var)
146
- - owner: Repository owner (or GITHUB_OWNER env var)
147
- - repo: Repository name (or GITHUB_REPO env var)
148
- - api_url: Optional API URL for GitHub Enterprise
149
- - use_projects_v2: Enable Projects v2 (default: False)
150
- - custom_priority_scheme: Custom priority label mapping
151
-
152
- """
153
- super().__init__(config)
154
-
155
- # Load configuration with environment variable resolution
156
- full_config = load_adapter_config("github", config)
157
-
158
- # Validate required configuration
159
- missing_keys = validate_adapter_config("github", full_config)
160
- if missing_keys:
161
- missing = ", ".join(missing_keys)
162
- raise ValueError(
163
- f"GitHub adapter missing required configuration: {missing}"
164
- )
165
-
166
- # Get authentication token - support 'api_key' and 'token'
167
- self.token = (
168
- full_config.get("api_key")
169
- or full_config.get("token")
170
- or full_config.get("token")
171
- )
172
-
173
- # Get repository information
174
- self.owner = full_config.get("owner")
175
- self.repo = full_config.get("repo")
176
-
177
- # API URLs
178
- self.api_url = config.get("api_url", "https://api.github.com")
179
- self.graphql_url = (
180
- f"{self.api_url}/graphql"
181
- if "github.com" in self.api_url
182
- else f"{self.api_url}/api/graphql"
183
- )
184
-
185
- # Configuration options
186
- self.use_projects_v2 = config.get("use_projects_v2", False)
187
- self.custom_priority_scheme = config.get("custom_priority_scheme", {})
188
-
189
- # HTTP client with authentication
190
- self.headers = {
191
- "Authorization": f"Bearer {self.token}",
192
- "Accept": "application/vnd.github.v3+json",
193
- "X-GitHub-Api-Version": "2022-11-28",
194
- }
195
-
196
- self.client = httpx.AsyncClient(
197
- base_url=self.api_url,
198
- headers=self.headers,
199
- timeout=30.0,
200
- )
201
-
202
- # Cache for labels and milestones
203
- self._labels_cache: list[dict[str, Any]] | None = None
204
- self._milestones_cache: list[dict[str, Any]] | None = None
205
- self._rate_limit: dict[str, Any] = {}
206
-
207
- def validate_credentials(self) -> tuple[bool, str]:
208
- """Validate that required credentials are present.
209
-
210
- Returns:
211
- (is_valid, error_message) - Tuple of validation result and error message
212
-
213
- """
214
- if not self.token:
215
- return (
216
- False,
217
- "GITHUB_TOKEN is required. Set it in .env.local or environment.",
218
- )
219
- if not self.owner:
220
- return (
221
- False,
222
- "GitHub owner is required. Set GITHUB_OWNER in .env.local "
223
- "or configure with 'mcp-ticketer init --adapter github "
224
- "--github-owner <owner>'",
225
- )
226
- if not self.repo:
227
- return (
228
- False,
229
- "GitHub repo is required. Set GITHUB_REPO in .env.local "
230
- "or configure with 'mcp-ticketer init --adapter github "
231
- "--github-repo <repo>'",
232
- )
233
- return True, ""
234
-
235
- def _get_state_mapping(self) -> dict[TicketState, str]:
236
- """Map universal states to GitHub states."""
237
- return {
238
- TicketState.OPEN: GitHubStateMapping.OPEN,
239
- TicketState.IN_PROGRESS: GitHubStateMapping.OPEN, # with label
240
- TicketState.READY: GitHubStateMapping.OPEN, # with label
241
- TicketState.TESTED: GitHubStateMapping.OPEN, # with label
242
- TicketState.DONE: GitHubStateMapping.CLOSED,
243
- TicketState.WAITING: GitHubStateMapping.OPEN, # with label
244
- TicketState.BLOCKED: GitHubStateMapping.OPEN, # with label
245
- TicketState.CLOSED: GitHubStateMapping.CLOSED,
246
- }
247
-
248
- def _get_state_label(self, state: TicketState) -> str | None:
249
- """Get the label name for extended states."""
250
- return GitHubStateMapping.STATE_LABELS.get(state)
251
-
252
- def _get_priority_from_labels(self, labels: list[str]) -> Priority:
253
- """Extract priority from issue labels."""
254
- label_names = [label.lower() for label in labels]
255
-
256
- # Check custom priority scheme first
257
- if self.custom_priority_scheme:
258
- for priority_str, label_patterns in self.custom_priority_scheme.items():
259
- for pattern in label_patterns:
260
- if any(pattern.lower() in label for label in label_names):
261
- return Priority(priority_str)
262
-
263
- # Check default priority labels
264
- for priority, priority_labels in GitHubStateMapping.PRIORITY_LABELS.items():
265
- for priority_label in priority_labels:
266
- if priority_label.lower() in label_names:
267
- return priority
268
-
269
- return Priority.MEDIUM
270
-
271
- def _get_priority_label(self, priority: Priority) -> str:
272
- """Get label name for a priority level."""
273
- # Check custom scheme first
274
- if self.custom_priority_scheme:
275
- labels = self.custom_priority_scheme.get(priority.value, [])
276
- if labels:
277
- return labels[0]
278
-
279
- # Use default labels
280
- labels = GitHubStateMapping.PRIORITY_LABELS.get(priority, [])
281
- return (
282
- labels[0]
283
- if labels
284
- else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
285
- )
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
-
320
- def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
321
- """Extract ticket state from GitHub issue data."""
322
- # Check if closed
323
- if issue["state"] == "closed":
324
- return TicketState.CLOSED
325
-
326
- # Check labels for extended states
327
- labels = []
328
- if "labels" in issue:
329
- if isinstance(issue["labels"], list):
330
- labels = [
331
- label.get("name", "") if isinstance(label, dict) else str(label)
332
- for label in issue["labels"]
333
- ]
334
- elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
335
- labels = [label["name"] for label in issue["labels"]["nodes"]]
336
-
337
- label_names = [label.lower() for label in labels]
338
-
339
- # Check for extended state labels
340
- for state, label_name in GitHubStateMapping.STATE_LABELS.items():
341
- if label_name.lower() in label_names:
342
- return state
343
-
344
- return TicketState.OPEN
345
-
346
- def _task_from_github_issue(self, issue: dict[str, Any]) -> Task:
347
- """Convert GitHub issue to universal Task."""
348
- # Extract labels
349
- labels = []
350
- if "labels" in issue:
351
- if isinstance(issue["labels"], list):
352
- labels = [
353
- label.get("name", "") if isinstance(label, dict) else str(label)
354
- for label in issue["labels"]
355
- ]
356
- elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
357
- labels = [label["name"] for label in issue["labels"]["nodes"]]
358
-
359
- # Extract state
360
- state = self._extract_state_from_issue(issue)
361
-
362
- # Extract priority
363
- priority = self._get_priority_from_labels(labels)
364
-
365
- # Extract assignee
366
- assignee = None
367
- if "assignees" in issue:
368
- if isinstance(issue["assignees"], list) and issue["assignees"]:
369
- assignee = issue["assignees"][0].get("login")
370
- elif isinstance(issue["assignees"], dict) and "nodes" in issue["assignees"]:
371
- nodes = issue["assignees"]["nodes"]
372
- if nodes:
373
- assignee = nodes[0].get("login")
374
- elif "assignee" in issue and issue["assignee"]:
375
- assignee = issue["assignee"].get("login")
376
-
377
- # Extract parent epic (milestone)
378
- parent_epic = None
379
- if issue.get("milestone"):
380
- parent_epic = str(issue["milestone"]["number"])
381
-
382
- # Parse dates
383
- created_at = None
384
- if issue.get("created_at"):
385
- created_at = datetime.fromisoformat(
386
- issue["created_at"].replace("Z", "+00:00")
387
- )
388
- elif issue.get("createdAt"):
389
- created_at = datetime.fromisoformat(
390
- issue["createdAt"].replace("Z", "+00:00")
391
- )
392
-
393
- updated_at = None
394
- if issue.get("updated_at"):
395
- updated_at = datetime.fromisoformat(
396
- issue["updated_at"].replace("Z", "+00:00")
397
- )
398
- elif issue.get("updatedAt"):
399
- updated_at = datetime.fromisoformat(
400
- issue["updatedAt"].replace("Z", "+00:00")
401
- )
402
-
403
- # Build metadata
404
- metadata = {
405
- "github": {
406
- "number": issue.get("number"),
407
- "url": issue.get("url") or issue.get("html_url"),
408
- "author": (
409
- issue.get("user", {}).get("login")
410
- if "user" in issue
411
- else issue.get("author", {}).get("login")
412
- ),
413
- "labels": labels,
414
- }
415
- }
416
-
417
- # Add projects v2 info if available
418
- if "projectCards" in issue and issue["projectCards"].get("nodes"):
419
- metadata["github"]["projects"] = [
420
- {
421
- "name": card["project"]["name"],
422
- "column": card["column"]["name"],
423
- "url": card["project"]["url"],
424
- }
425
- for card in issue["projectCards"]["nodes"]
426
- ]
427
-
428
- return Task(
429
- id=str(issue["number"]),
430
- title=issue["title"],
431
- description=issue.get("body") or issue.get("bodyText"),
432
- state=state,
433
- priority=priority,
434
- tags=labels,
435
- parent_epic=parent_epic,
436
- assignee=assignee,
437
- created_at=created_at,
438
- updated_at=updated_at,
439
- metadata=metadata,
440
- )
441
-
442
- async def _ensure_label_exists(
443
- self, label_name: str, color: str = "0366d6"
444
- ) -> None:
445
- """Ensure a label exists in the repository."""
446
- if not self._labels_cache:
447
- response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
448
- response.raise_for_status()
449
- self._labels_cache = response.json()
450
-
451
- # Check if label exists
452
- existing_labels = [label["name"].lower() for label in self._labels_cache]
453
- if label_name.lower() not in existing_labels:
454
- # Create the label
455
- response = await self.client.post(
456
- f"/repos/{self.owner}/{self.repo}/labels",
457
- json={"name": label_name, "color": color},
458
- )
459
- if response.status_code == 201:
460
- self._labels_cache.append(response.json())
461
-
462
- async def _graphql_request(
463
- self, query: str, variables: dict[str, Any]
464
- ) -> dict[str, Any]:
465
- """Execute a GraphQL query."""
466
- response = await self.client.post(
467
- self.graphql_url, json={"query": query, "variables": variables}
468
- )
469
- response.raise_for_status()
470
-
471
- data = response.json()
472
- if "errors" in data:
473
- raise ValueError(f"GraphQL errors: {data['errors']}")
474
-
475
- return data["data"]
476
-
477
- async def create(self, ticket: Task) -> Task:
478
- """Create a new GitHub issue."""
479
- # Validate credentials before attempting operation
480
- is_valid, error_message = self.validate_credentials()
481
- if not is_valid:
482
- raise ValueError(error_message)
483
-
484
- # Prepare labels
485
- labels = ticket.tags.copy() if ticket.tags else []
486
-
487
- # Add state label if needed
488
- state_label = self._get_state_label(ticket.state)
489
- if state_label:
490
- labels.append(state_label)
491
- await self._ensure_label_exists(state_label, "fbca04")
492
-
493
- # Add priority label
494
- priority_label = self._get_priority_label(ticket.priority)
495
- labels.append(priority_label)
496
- await self._ensure_label_exists(priority_label, "d73a4a")
497
-
498
- # Ensure all labels exist
499
- for label in labels:
500
- await self._ensure_label_exists(label)
501
-
502
- # Build issue data
503
- issue_data = {
504
- "title": ticket.title,
505
- "body": ticket.description or "",
506
- "labels": labels,
507
- }
508
-
509
- # Add assignee if specified
510
- if ticket.assignee:
511
- issue_data["assignees"] = [ticket.assignee]
512
-
513
- # Add milestone if parent_epic is specified
514
- if ticket.parent_epic:
515
- try:
516
- milestone_number = int(ticket.parent_epic)
517
- issue_data["milestone"] = milestone_number
518
- except ValueError:
519
- # Try to find milestone by title
520
- if not self._milestones_cache:
521
- response = await self.client.get(
522
- f"/repos/{self.owner}/{self.repo}/milestones"
523
- )
524
- response.raise_for_status()
525
- self._milestones_cache = response.json()
526
-
527
- for milestone in self._milestones_cache:
528
- if milestone["title"] == ticket.parent_epic:
529
- issue_data["milestone"] = milestone["number"]
530
- break
531
-
532
- # Create the issue
533
- response = await self.client.post(
534
- f"/repos/{self.owner}/{self.repo}/issues", json=issue_data
535
- )
536
- response.raise_for_status()
537
-
538
- created_issue = response.json()
539
-
540
- # If state requires closing, close the issue
541
- if ticket.state in [TicketState.DONE, TicketState.CLOSED]:
542
- await self.client.patch(
543
- f"/repos/{self.owner}/{self.repo}/issues/{created_issue['number']}",
544
- json={"state": "closed"},
545
- )
546
- created_issue["state"] = "closed"
547
-
548
- return self._task_from_github_issue(created_issue)
549
-
550
- async def read(self, ticket_id: str) -> Task | None:
551
- """Read a GitHub issue by number."""
552
- # Validate credentials before attempting operation
553
- is_valid, error_message = self.validate_credentials()
554
- if not is_valid:
555
- raise ValueError(error_message)
556
-
557
- try:
558
- issue_number = int(ticket_id)
559
- except ValueError:
560
- return None
561
-
562
- try:
563
- response = await self.client.get(
564
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
565
- )
566
- if response.status_code == 404:
567
- return None
568
- response.raise_for_status()
569
-
570
- issue = response.json()
571
- return self._task_from_github_issue(issue)
572
- except httpx.HTTPError:
573
- return None
574
-
575
- async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
576
- """Update a GitHub issue."""
577
- # Validate credentials before attempting operation
578
- is_valid, error_message = self.validate_credentials()
579
- if not is_valid:
580
- raise ValueError(error_message)
581
-
582
- try:
583
- issue_number = int(ticket_id)
584
- except ValueError:
585
- return None
586
-
587
- # Get current issue to preserve labels
588
- response = await self.client.get(
589
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
590
- )
591
- if response.status_code == 404:
592
- return None
593
- response.raise_for_status()
594
-
595
- current_issue = response.json()
596
- current_labels = [label["name"] for label in current_issue.get("labels", [])]
597
-
598
- # Build update data
599
- update_data = {}
600
-
601
- if "title" in updates:
602
- update_data["title"] = updates["title"]
603
-
604
- if "description" in updates:
605
- update_data["body"] = updates["description"]
606
-
607
- # Handle state updates
608
- if "state" in updates:
609
- new_state = updates["state"]
610
- if isinstance(new_state, str):
611
- new_state = TicketState(new_state)
612
-
613
- # Remove old state labels
614
- labels_to_update = [
615
- label
616
- for label in current_labels
617
- if label.lower()
618
- not in [sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()]
619
- ]
620
-
621
- # Add new state label if needed
622
- state_label = self._get_state_label(new_state)
623
- if state_label:
624
- await self._ensure_label_exists(state_label, "fbca04")
625
- labels_to_update.append(state_label)
626
-
627
- update_data["labels"] = labels_to_update
628
-
629
- # Update issue state if needed
630
- if new_state in [TicketState.DONE, TicketState.CLOSED]:
631
- update_data["state"] = "closed"
632
- else:
633
- update_data["state"] = "open"
634
-
635
- # Handle priority updates
636
- if "priority" in updates:
637
- new_priority = updates["priority"]
638
- if isinstance(new_priority, str):
639
- new_priority = Priority(new_priority)
640
-
641
- # Remove old priority labels
642
- labels_to_update = update_data.get("labels", current_labels)
643
- all_priority_labels = []
644
- for labels in GitHubStateMapping.PRIORITY_LABELS.values():
645
- all_priority_labels.extend([label.lower() for label in labels])
646
-
647
- labels_to_update = [
648
- label
649
- for label in labels_to_update
650
- if label.lower() not in all_priority_labels
651
- and not re.match(r"^P[0-3]$", label, re.IGNORECASE)
652
- ]
653
-
654
- # Add new priority label
655
- priority_label = self._get_priority_label(new_priority)
656
- await self._ensure_label_exists(priority_label, "d73a4a")
657
- labels_to_update.append(priority_label)
658
-
659
- update_data["labels"] = labels_to_update
660
-
661
- # Handle assignee updates
662
- if "assignee" in updates:
663
- if updates["assignee"]:
664
- update_data["assignees"] = [updates["assignee"]]
665
- else:
666
- update_data["assignees"] = []
667
-
668
- # Handle tags updates
669
- if "tags" in updates:
670
- # Preserve state and priority labels
671
- preserved_labels = []
672
- for label in current_labels:
673
- if label.lower() in [
674
- sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()
675
- ]:
676
- preserved_labels.append(label)
677
- elif any(
678
- label.lower() in [pl.lower() for pl in labels]
679
- for labels in GitHubStateMapping.PRIORITY_LABELS.values()
680
- ):
681
- preserved_labels.append(label)
682
- elif re.match(r"^P[0-3]$", label, re.IGNORECASE):
683
- preserved_labels.append(label)
684
-
685
- # Add new tags
686
- for tag in updates["tags"]:
687
- await self._ensure_label_exists(tag)
688
-
689
- update_data["labels"] = preserved_labels + updates["tags"]
690
-
691
- # Apply updates
692
- if update_data:
693
- response = await self.client.patch(
694
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
695
- json=update_data,
696
- )
697
- response.raise_for_status()
698
-
699
- updated_issue = response.json()
700
- return self._task_from_github_issue(updated_issue)
701
-
702
- return await self.read(ticket_id)
703
-
704
- async def delete(self, ticket_id: str) -> bool:
705
- """Delete (close) a GitHub issue."""
706
- # Validate credentials before attempting operation
707
- is_valid, error_message = self.validate_credentials()
708
- if not is_valid:
709
- raise ValueError(error_message)
710
-
711
- try:
712
- issue_number = int(ticket_id)
713
- except ValueError:
714
- return False
715
-
716
- try:
717
- response = await self.client.patch(
718
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
719
- json={"state": "closed", "state_reason": "not_planned"},
720
- )
721
- response.raise_for_status()
722
- return True
723
- except httpx.HTTPError:
724
- return False
725
-
726
- async def list(
727
- self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
728
- ) -> list[Task]:
729
- """List GitHub issues with filters."""
730
- # Build query parameters
731
- params: dict[str, Any] = {
732
- "per_page": min(limit, 100), # GitHub max is 100
733
- "page": (offset // limit) + 1 if limit > 0 else 1,
734
- }
735
-
736
- if filters:
737
- # State filter
738
- if "state" in filters:
739
- state = filters["state"]
740
- if isinstance(state, str):
741
- state = TicketState(state)
742
-
743
- if state in [TicketState.DONE, TicketState.CLOSED]:
744
- params["state"] = "closed"
745
- else:
746
- params["state"] = "open"
747
- # Add label filter for extended states
748
- state_label = self._get_state_label(state)
749
- if state_label:
750
- params["labels"] = state_label
751
-
752
- # Priority filter via labels
753
- if "priority" in filters:
754
- priority = filters["priority"]
755
- if isinstance(priority, str):
756
- priority = Priority(priority)
757
- priority_label = self._get_priority_label(priority)
758
-
759
- if "labels" in params:
760
- params["labels"] += f",{priority_label}"
761
- else:
762
- params["labels"] = priority_label
763
-
764
- # Assignee filter
765
- if "assignee" in filters:
766
- params["assignee"] = filters["assignee"]
767
-
768
- # Milestone filter (parent_epic)
769
- if "parent_epic" in filters:
770
- params["milestone"] = filters["parent_epic"]
771
-
772
- response = await self.client.get(
773
- f"/repos/{self.owner}/{self.repo}/issues", params=params
774
- )
775
- response.raise_for_status()
776
-
777
- issues = response.json()
778
-
779
- # Store rate limit info
780
- self._rate_limit = {
781
- "limit": response.headers.get("X-RateLimit-Limit"),
782
- "remaining": response.headers.get("X-RateLimit-Remaining"),
783
- "reset": response.headers.get("X-RateLimit-Reset"),
784
- }
785
-
786
- # Filter out pull requests (they appear as issues in the API)
787
- issues = [issue for issue in issues if "pull_request" not in issue]
788
-
789
- return [self._task_from_github_issue(issue) for issue in issues]
790
-
791
- async def search(self, query: SearchQuery) -> builtins.list[Task]:
792
- """Search GitHub issues using advanced search syntax."""
793
- # Build GitHub search query
794
- search_parts = [f"repo:{self.owner}/{self.repo}", "is:issue"]
795
-
796
- # Text search
797
- if query.query:
798
- # Escape special characters for GitHub search
799
- escaped_query = query.query.replace('"', '\\"')
800
- search_parts.append(f'"{escaped_query}"')
801
-
802
- # State filter
803
- if query.state:
804
- if query.state in [TicketState.DONE, TicketState.CLOSED]:
805
- search_parts.append("is:closed")
806
- else:
807
- search_parts.append("is:open")
808
- # Add label filter for extended states
809
- state_label = self._get_state_label(query.state)
810
- if state_label:
811
- search_parts.append(f'label:"{state_label}"')
812
-
813
- # Priority filter
814
- if query.priority:
815
- priority_label = self._get_priority_label(query.priority)
816
- search_parts.append(f'label:"{priority_label}"')
817
-
818
- # Assignee filter
819
- if query.assignee:
820
- search_parts.append(f"assignee:{query.assignee}")
821
-
822
- # Tags filter
823
- if query.tags:
824
- for tag in query.tags:
825
- search_parts.append(f'label:"{tag}"')
826
-
827
- # Build final search query
828
- github_query = " ".join(search_parts)
829
-
830
- # Use GraphQL for better search capabilities
831
- full_query = (
832
- GitHubGraphQLQueries.ISSUE_FRAGMENT + GitHubGraphQLQueries.SEARCH_ISSUES
833
- )
834
-
835
- variables = {
836
- "query": github_query,
837
- "first": min(query.limit, 100),
838
- "after": None,
839
- }
840
-
841
- # Handle pagination for offset
842
- if query.offset > 0:
843
- # We need to paginate through to get to the offset
844
- # This is inefficient but GitHub doesn't support direct offset
845
- pages_to_skip = query.offset // 100
846
- for _ in range(pages_to_skip):
847
- temp_result = await self._graphql_request(full_query, variables)
848
- page_info = temp_result["search"]["pageInfo"]
849
- if page_info["hasNextPage"]:
850
- variables["after"] = page_info["endCursor"]
851
- else:
852
- return [] # Offset beyond available results
853
-
854
- result = await self._graphql_request(full_query, variables)
855
-
856
- issues = []
857
- for node in result["search"]["nodes"]:
858
- if node: # Some nodes might be null
859
- # Convert GraphQL format to REST format for consistency
860
- rest_format = {
861
- "number": node["number"],
862
- "title": node["title"],
863
- "body": node["body"],
864
- "state": node["state"].lower(),
865
- "created_at": node["createdAt"],
866
- "updated_at": node["updatedAt"],
867
- "html_url": node["url"],
868
- "labels": node.get("labels", {}).get("nodes", []),
869
- "milestone": node.get("milestone"),
870
- "assignees": node.get("assignees", {}).get("nodes", []),
871
- "author": node.get("author"),
872
- }
873
- issues.append(self._task_from_github_issue(rest_format))
874
-
875
- return issues
876
-
877
- async def transition_state(
878
- self, ticket_id: str, target_state: TicketState
879
- ) -> Task | None:
880
- """Transition GitHub issue to a new state."""
881
- # Validate transition
882
- if not await self.validate_transition(ticket_id, target_state):
883
- return None
884
-
885
- # Update state
886
- return await self.update(ticket_id, {"state": target_state})
887
-
888
- async def add_comment(self, comment: Comment) -> Comment:
889
- """Add a comment to a GitHub issue."""
890
- try:
891
- issue_number = int(comment.ticket_id)
892
- except ValueError as e:
893
- raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e
894
-
895
- # Create comment
896
- response = await self.client.post(
897
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
898
- json={"body": comment.content},
899
- )
900
- response.raise_for_status()
901
-
902
- created_comment = response.json()
903
-
904
- return Comment(
905
- id=str(created_comment["id"]),
906
- ticket_id=comment.ticket_id,
907
- author=created_comment["user"]["login"],
908
- content=created_comment["body"],
909
- created_at=datetime.fromisoformat(
910
- created_comment["created_at"].replace("Z", "+00:00")
911
- ),
912
- metadata={
913
- "github": {
914
- "id": created_comment["id"],
915
- "url": created_comment["html_url"],
916
- "author_avatar": created_comment["user"]["avatar_url"],
917
- }
918
- },
919
- )
920
-
921
- async def get_comments(
922
- self, ticket_id: str, limit: int = 10, offset: int = 0
923
- ) -> builtins.list[Comment]:
924
- """Get comments for a GitHub issue."""
925
- try:
926
- issue_number = int(ticket_id)
927
- except ValueError:
928
- return []
929
-
930
- params = {
931
- "per_page": min(limit, 100),
932
- "page": (offset // limit) + 1 if limit > 0 else 1,
933
- }
934
-
935
- try:
936
- response = await self.client.get(
937
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
938
- params=params,
939
- )
940
- response.raise_for_status()
941
-
942
- comments = []
943
- for comment_data in response.json():
944
- comments.append(
945
- Comment(
946
- id=str(comment_data["id"]),
947
- ticket_id=ticket_id,
948
- author=comment_data["user"]["login"],
949
- content=comment_data["body"],
950
- created_at=datetime.fromisoformat(
951
- comment_data["created_at"].replace("Z", "+00:00")
952
- ),
953
- metadata={
954
- "github": {
955
- "id": comment_data["id"],
956
- "url": comment_data["html_url"],
957
- "author_avatar": comment_data["user"]["avatar_url"],
958
- }
959
- },
960
- )
961
- )
962
-
963
- return comments
964
- except httpx.HTTPError:
965
- return []
966
-
967
- async def get_rate_limit(self) -> dict[str, Any]:
968
- """Get current rate limit status."""
969
- response = await self.client.get("/rate_limit")
970
- response.raise_for_status()
971
- return response.json()
972
-
973
- async def create_milestone(self, epic: Epic) -> Epic:
974
- """Create a GitHub milestone as an Epic."""
975
- milestone_data = {
976
- "title": epic.title,
977
- "description": epic.description or "",
978
- "state": "open" if epic.state != TicketState.CLOSED else "closed",
979
- }
980
-
981
- response = await self.client.post(
982
- f"/repos/{self.owner}/{self.repo}/milestones", json=milestone_data
983
- )
984
- response.raise_for_status()
985
-
986
- created_milestone = response.json()
987
- return self._milestone_to_epic(created_milestone)
988
-
989
- async def get_milestone(self, milestone_number: int) -> Epic | None:
990
- """Get a GitHub milestone as an Epic."""
991
- try:
992
- response = await self.client.get(
993
- f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
994
- )
995
- if response.status_code == 404:
996
- return None
997
- response.raise_for_status()
998
-
999
- milestone = response.json()
1000
- return self._milestone_to_epic(milestone)
1001
- except httpx.HTTPError:
1002
- return None
1003
-
1004
- async def list_milestones(
1005
- self, state: str = "open", limit: int = 10, offset: int = 0
1006
- ) -> builtins.list[Epic]:
1007
- """List GitHub milestones as Epics."""
1008
- params = {
1009
- "state": state,
1010
- "per_page": min(limit, 100),
1011
- "page": (offset // limit) + 1 if limit > 0 else 1,
1012
- }
1013
-
1014
- response = await self.client.get(
1015
- f"/repos/{self.owner}/{self.repo}/milestones", params=params
1016
- )
1017
- response.raise_for_status()
1018
-
1019
- return [self._milestone_to_epic(milestone) for milestone in response.json()]
1020
-
1021
- async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
1022
- """Link an issue to a pull request using keywords."""
1023
- # This is typically done through PR description keywords like "fixes #123"
1024
- # We can add a comment to track the link
1025
- comment = f"Linked to PR #{pr_number}"
1026
-
1027
- response = await self.client.post(
1028
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
1029
- json={"body": comment},
1030
- )
1031
-
1032
- return response.status_code == 201
1033
-
1034
- async def create_pull_request(
1035
- self,
1036
- ticket_id: str,
1037
- base_branch: str = "main",
1038
- head_branch: str | None = None,
1039
- title: str | None = None,
1040
- body: str | None = None,
1041
- draft: bool = False,
1042
- ) -> dict[str, Any]:
1043
- """Create a pull request linked to an issue.
1044
-
1045
- Args:
1046
- ticket_id: Issue number to link the PR to
1047
- base_branch: Target branch for the PR (default: main)
1048
- head_branch: Source branch name (auto-generated if not provided)
1049
- title: PR title (uses ticket title if not provided)
1050
- body: PR description (auto-generated with issue link if not provided)
1051
- draft: Create as draft PR
1052
-
1053
- Returns:
1054
- Dictionary with PR details including number, url, and branch
1055
-
1056
- """
1057
- try:
1058
- issue_number = int(ticket_id)
1059
- except ValueError as e:
1060
- raise ValueError(f"Invalid issue number: {ticket_id}") from e
1061
-
1062
- # Get the issue details
1063
- issue = await self.read(ticket_id)
1064
- if not issue:
1065
- raise ValueError(f"Issue #{ticket_id} not found")
1066
-
1067
- # Auto-generate branch name if not provided
1068
- if not head_branch:
1069
- # Create branch name from issue number and title
1070
- # e.g., "123-fix-authentication-bug"
1071
- safe_title = "-".join(
1072
- issue.title.lower()
1073
- .replace("[", "")
1074
- .replace("]", "")
1075
- .replace("#", "")
1076
- .replace("/", "-")
1077
- .replace("\\", "-")
1078
- .split()[:5] # Limit to 5 words
1079
- )
1080
- head_branch = f"{issue_number}-{safe_title}"
1081
-
1082
- # Auto-generate title if not provided
1083
- if not title:
1084
- # Include issue number in PR title
1085
- title = f"[#{issue_number}] {issue.title}"
1086
-
1087
- # Auto-generate body if not provided
1088
- if not body:
1089
- body = f"""## Summary
1090
-
1091
- This PR addresses issue #{issue_number}.
1092
-
1093
- **Issue:** #{issue_number} - {issue.title}
1094
- **Link:** {issue.metadata.get('github', {}).get('url', '')}
1095
-
1096
- ## Description
1097
-
1098
- {issue.description or 'No description provided.'}
1099
-
1100
- ## Changes
1101
-
1102
- - [ ] Implementation details to be added
1103
-
1104
- ## Testing
1105
-
1106
- - [ ] Tests have been added/updated
1107
- - [ ] All tests pass
1108
-
1109
- ## Checklist
1110
-
1111
- - [ ] Code follows project style guidelines
1112
- - [ ] Self-review completed
1113
- - [ ] Documentation updated if needed
1114
-
1115
- Fixes #{issue_number}
1116
- """
1117
-
1118
- # Check if the head branch exists
1119
- try:
1120
- branch_response = await self.client.get(
1121
- f"/repos/{self.owner}/{self.repo}/branches/{head_branch}"
1122
- )
1123
- branch_exists = branch_response.status_code == 200
1124
- except httpx.HTTPError:
1125
- branch_exists = False
1126
-
1127
- if not branch_exists:
1128
- # Get the base branch SHA
1129
- base_response = await self.client.get(
1130
- f"/repos/{self.owner}/{self.repo}/branches/{base_branch}"
1131
- )
1132
- base_response.raise_for_status()
1133
- base_sha = base_response.json()["commit"]["sha"]
1134
-
1135
- # Create the new branch
1136
- ref_response = await self.client.post(
1137
- f"/repos/{self.owner}/{self.repo}/git/refs",
1138
- json={
1139
- "ref": f"refs/heads/{head_branch}",
1140
- "sha": base_sha,
1141
- },
1142
- )
1143
-
1144
- if ref_response.status_code != 201:
1145
- # Branch might already exist on remote, try to use it
1146
- pass
1147
-
1148
- # Create the pull request
1149
- pr_data = {
1150
- "title": title,
1151
- "body": body,
1152
- "head": head_branch,
1153
- "base": base_branch,
1154
- "draft": draft,
1155
- }
1156
-
1157
- pr_response = await self.client.post(
1158
- f"/repos/{self.owner}/{self.repo}/pulls", json=pr_data
1159
- )
1160
-
1161
- if pr_response.status_code == 422:
1162
- # PR might already exist, try to get it
1163
- search_response = await self.client.get(
1164
- f"/repos/{self.owner}/{self.repo}/pulls",
1165
- params={
1166
- "head": f"{self.owner}:{head_branch}",
1167
- "base": base_branch,
1168
- "state": "open",
1169
- },
1170
- )
1171
-
1172
- if search_response.status_code == 200:
1173
- existing_prs = search_response.json()
1174
- if existing_prs:
1175
- pr = existing_prs[0]
1176
- return {
1177
- "number": pr["number"],
1178
- "url": pr["html_url"],
1179
- "api_url": pr["url"],
1180
- "branch": head_branch,
1181
- "state": pr["state"],
1182
- "draft": pr.get("draft", False),
1183
- "title": pr["title"],
1184
- "existing": True,
1185
- "linked_issue": issue_number,
1186
- }
1187
-
1188
- raise ValueError(f"Failed to create PR: {pr_response.text}")
1189
-
1190
- pr_response.raise_for_status()
1191
- pr = pr_response.json()
1192
-
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']}"
1195
- await self.add_comment(
1196
- Comment(
1197
- ticket_id=ticket_id,
1198
- content=pr_msg,
1199
- author="system",
1200
- )
1201
- )
1202
-
1203
- return {
1204
- "number": pr["number"],
1205
- "url": pr["html_url"],
1206
- "api_url": pr["url"],
1207
- "branch": head_branch,
1208
- "state": pr["state"],
1209
- "draft": pr.get("draft", False),
1210
- "title": pr["title"],
1211
- "linked_issue": issue_number,
1212
- }
1213
-
1214
- async def link_existing_pull_request(
1215
- self,
1216
- ticket_id: str,
1217
- pr_url: str,
1218
- ) -> dict[str, Any]:
1219
- """Link an existing pull request to a ticket.
1220
-
1221
- Args:
1222
- ticket_id: Issue number to link the PR to
1223
- pr_url: GitHub PR URL to link
1224
-
1225
- Returns:
1226
- Dictionary with link status and PR details
1227
-
1228
- """
1229
- try:
1230
- issue_number = int(ticket_id)
1231
- except ValueError as e:
1232
- raise ValueError(f"Invalid issue number: {ticket_id}") from e
1233
-
1234
- # Parse PR URL to extract owner, repo, and PR number
1235
- # Expected format: https://github.com/owner/repo/pull/123
1236
- import re
1237
-
1238
- pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
1239
- match = re.search(pr_pattern, pr_url)
1240
-
1241
- if not match:
1242
- raise ValueError(f"Invalid GitHub PR URL format: {pr_url}")
1243
-
1244
- pr_owner, pr_repo, pr_number = match.groups()
1245
-
1246
- # Verify the PR is from the same repository
1247
- if pr_owner != self.owner or pr_repo != self.repo:
1248
- raise ValueError(
1249
- f"PR must be from the same repository ({self.owner}/{self.repo})"
1250
- )
1251
-
1252
- # Get PR details
1253
- pr_response = await self.client.get(
1254
- f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
1255
- )
1256
-
1257
- if pr_response.status_code == 404:
1258
- raise ValueError(f"Pull request #{pr_number} not found")
1259
-
1260
- pr_response.raise_for_status()
1261
- pr = pr_response.json()
1262
-
1263
- # Update PR body to include issue reference if not already present
1264
- current_body = pr.get("body", "")
1265
- issue_ref = f"#{issue_number}"
1266
-
1267
- if issue_ref not in current_body:
1268
- # Add issue reference to the body
1269
- updated_body = current_body or ""
1270
- if updated_body:
1271
- updated_body += "\n\n"
1272
- updated_body += f"Related to #{issue_number}"
1273
-
1274
- # Update the PR
1275
- update_response = await self.client.patch(
1276
- f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}",
1277
- json={"body": updated_body},
1278
- )
1279
- update_response.raise_for_status()
1280
-
1281
- # Add a comment to the issue about the PR
1282
- await self.add_comment(
1283
- Comment(
1284
- ticket_id=ticket_id,
1285
- content=f"Linked to pull request #{pr_number}: {pr_url}",
1286
- author="system",
1287
- )
1288
- )
1289
-
1290
- return {
1291
- "success": True,
1292
- "pr_number": pr["number"],
1293
- "pr_url": pr["html_url"],
1294
- "pr_title": pr["title"],
1295
- "pr_state": pr["state"],
1296
- "linked_issue": issue_number,
1297
- "message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
1298
- }
1299
-
1300
- async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
1301
- """Get repository collaborators."""
1302
- response = await self.client.get(
1303
- f"/repos/{self.owner}/{self.repo}/collaborators"
1304
- )
1305
- response.raise_for_status()
1306
- return response.json()
1307
-
1308
- async def get_current_user(self) -> dict[str, Any] | None:
1309
- """Get current authenticated user information."""
1310
- response = await self.client.get("/user")
1311
- response.raise_for_status()
1312
- return response.json()
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
-
1568
- async def close(self) -> None:
1569
- """Close the HTTP client connection."""
1570
- await self.client.aclose()
1571
-
1572
-
1573
- # Register the adapter
1574
- AdapterRegistry.register("github", GitHubAdapter)