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