mcp-ticketer 0.1.1__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.

@@ -0,0 +1,974 @@
1
+ """GitHub adapter implementation using REST API v3 and GraphQL API v4."""
2
+
3
+ import os
4
+ import re
5
+ import asyncio
6
+ from typing import List, Optional, Dict, Any, Union, Tuple
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ import json
10
+
11
+ import httpx
12
+ from pydantic import BaseModel
13
+
14
+ from ..core.adapter import BaseAdapter
15
+ from ..core.models import Epic, Task, Comment, SearchQuery, TicketState, Priority
16
+ from ..core.registry import AdapterRegistry
17
+
18
+
19
+ class GitHubStateMapping:
20
+ """GitHub issue states and label-based extended states."""
21
+
22
+ # GitHub native states
23
+ OPEN = "open"
24
+ CLOSED = "closed"
25
+
26
+ # Extended states via labels
27
+ STATE_LABELS = {
28
+ TicketState.IN_PROGRESS: "in-progress",
29
+ TicketState.READY: "ready",
30
+ TicketState.TESTED: "tested",
31
+ TicketState.WAITING: "waiting",
32
+ TicketState.BLOCKED: "blocked",
33
+ }
34
+
35
+ # Priority labels
36
+ PRIORITY_LABELS = {
37
+ Priority.CRITICAL: ["P0", "critical", "urgent"],
38
+ Priority.HIGH: ["P1", "high"],
39
+ Priority.MEDIUM: ["P2", "medium"],
40
+ Priority.LOW: ["P3", "low"],
41
+ }
42
+
43
+
44
+ class GitHubGraphQLQueries:
45
+ """GraphQL queries for GitHub API v4."""
46
+
47
+ ISSUE_FRAGMENT = """
48
+ fragment IssueFields on Issue {
49
+ id
50
+ number
51
+ title
52
+ body
53
+ state
54
+ createdAt
55
+ updatedAt
56
+ url
57
+ author {
58
+ login
59
+ }
60
+ assignees(first: 10) {
61
+ nodes {
62
+ login
63
+ email
64
+ }
65
+ }
66
+ labels(first: 20) {
67
+ nodes {
68
+ name
69
+ color
70
+ }
71
+ }
72
+ milestone {
73
+ id
74
+ number
75
+ title
76
+ state
77
+ description
78
+ }
79
+ projectCards(first: 10) {
80
+ nodes {
81
+ project {
82
+ name
83
+ url
84
+ }
85
+ column {
86
+ name
87
+ }
88
+ }
89
+ }
90
+ comments(first: 100) {
91
+ nodes {
92
+ id
93
+ body
94
+ author {
95
+ login
96
+ }
97
+ createdAt
98
+ }
99
+ }
100
+ reactions(first: 10) {
101
+ nodes {
102
+ content
103
+ user {
104
+ login
105
+ }
106
+ }
107
+ }
108
+ }
109
+ """
110
+
111
+ GET_ISSUE = """
112
+ query GetIssue($owner: String!, $repo: String!, $number: Int!) {
113
+ repository(owner: $owner, name: $repo) {
114
+ issue(number: $number) {
115
+ ...IssueFields
116
+ }
117
+ }
118
+ }
119
+ """
120
+
121
+ SEARCH_ISSUES = """
122
+ query SearchIssues($query: String!, $first: Int!, $after: String) {
123
+ search(query: $query, type: ISSUE, first: $first, after: $after) {
124
+ issueCount
125
+ pageInfo {
126
+ hasNextPage
127
+ endCursor
128
+ }
129
+ nodes {
130
+ ... on Issue {
131
+ ...IssueFields
132
+ }
133
+ }
134
+ }
135
+ }
136
+ """
137
+
138
+
139
+ class GitHubAdapter(BaseAdapter[Task]):
140
+ """Adapter for GitHub Issues tracking system."""
141
+
142
+ def __init__(self, config: Dict[str, Any]):
143
+ """Initialize GitHub adapter.
144
+
145
+ Args:
146
+ config: Configuration with:
147
+ - token: GitHub Personal Access Token (or GITHUB_TOKEN env var)
148
+ - owner: Repository owner (or GITHUB_OWNER env var)
149
+ - repo: Repository name (or GITHUB_REPO env var)
150
+ - api_url: Optional API URL for GitHub Enterprise (defaults to github.com)
151
+ - use_projects_v2: Enable GitHub Projects v2 integration (default: False)
152
+ - custom_priority_scheme: Custom priority label mapping
153
+ """
154
+ super().__init__(config)
155
+
156
+ # Get authentication token - support both 'api_key' and 'token' for compatibility
157
+ self.token = config.get("api_key") or config.get("token") or os.getenv("GITHUB_TOKEN")
158
+ if not self.token:
159
+ raise ValueError("GitHub token required (config.api_key, config.token or GITHUB_TOKEN env var)")
160
+
161
+ # Get repository information
162
+ self.owner = config.get("owner") or os.getenv("GITHUB_OWNER")
163
+ self.repo = config.get("repo") or os.getenv("GITHUB_REPO")
164
+
165
+ if not self.owner or not self.repo:
166
+ raise ValueError("GitHub owner and repo are required")
167
+
168
+ # API URLs
169
+ self.api_url = config.get("api_url", "https://api.github.com")
170
+ self.graphql_url = f"{self.api_url}/graphql" if "github.com" in self.api_url else f"{self.api_url}/api/graphql"
171
+
172
+ # Configuration options
173
+ self.use_projects_v2 = config.get("use_projects_v2", False)
174
+ self.custom_priority_scheme = config.get("custom_priority_scheme", {})
175
+
176
+ # HTTP client with authentication
177
+ self.headers = {
178
+ "Authorization": f"Bearer {self.token}",
179
+ "Accept": "application/vnd.github.v3+json",
180
+ "X-GitHub-Api-Version": "2022-11-28",
181
+ }
182
+
183
+ self.client = httpx.AsyncClient(
184
+ base_url=self.api_url,
185
+ headers=self.headers,
186
+ timeout=30.0,
187
+ )
188
+
189
+ # Cache for labels and milestones
190
+ self._labels_cache: Optional[List[Dict[str, Any]]] = None
191
+ self._milestones_cache: Optional[List[Dict[str, Any]]] = None
192
+ self._rate_limit: Dict[str, Any] = {}
193
+
194
+ def _get_state_mapping(self) -> Dict[TicketState, str]:
195
+ """Map universal states to GitHub states."""
196
+ return {
197
+ TicketState.OPEN: GitHubStateMapping.OPEN,
198
+ TicketState.IN_PROGRESS: GitHubStateMapping.OPEN, # with label
199
+ TicketState.READY: GitHubStateMapping.OPEN, # with label
200
+ TicketState.TESTED: GitHubStateMapping.OPEN, # with label
201
+ TicketState.DONE: GitHubStateMapping.CLOSED,
202
+ TicketState.WAITING: GitHubStateMapping.OPEN, # with label
203
+ TicketState.BLOCKED: GitHubStateMapping.OPEN, # with label
204
+ TicketState.CLOSED: GitHubStateMapping.CLOSED,
205
+ }
206
+
207
+ def _get_state_label(self, state: TicketState) -> Optional[str]:
208
+ """Get the label name for extended states."""
209
+ return GitHubStateMapping.STATE_LABELS.get(state)
210
+
211
+ def _get_priority_from_labels(self, labels: List[str]) -> Priority:
212
+ """Extract priority from issue labels."""
213
+ label_names = [label.lower() for label in labels]
214
+
215
+ # Check custom priority scheme first
216
+ if self.custom_priority_scheme:
217
+ for priority_str, label_patterns in self.custom_priority_scheme.items():
218
+ for pattern in label_patterns:
219
+ if any(pattern.lower() in label for label in label_names):
220
+ return Priority(priority_str)
221
+
222
+ # Check default priority labels
223
+ for priority, priority_labels in GitHubStateMapping.PRIORITY_LABELS.items():
224
+ for priority_label in priority_labels:
225
+ if priority_label.lower() in label_names:
226
+ return priority
227
+
228
+ return Priority.MEDIUM
229
+
230
+ def _get_priority_label(self, priority: Priority) -> str:
231
+ """Get label name for a priority level."""
232
+ # Check custom scheme first
233
+ if self.custom_priority_scheme:
234
+ labels = self.custom_priority_scheme.get(priority.value, [])
235
+ if labels:
236
+ return labels[0]
237
+
238
+ # Use default labels
239
+ labels = GitHubStateMapping.PRIORITY_LABELS.get(priority, [])
240
+ return labels[0] if labels else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
241
+
242
+ def _extract_state_from_issue(self, issue: Dict[str, Any]) -> TicketState:
243
+ """Extract ticket state from GitHub issue data."""
244
+ # Check if closed
245
+ if issue["state"] == "closed":
246
+ return TicketState.CLOSED
247
+
248
+ # Check labels for extended states
249
+ labels = []
250
+ if "labels" in issue:
251
+ if isinstance(issue["labels"], list):
252
+ labels = [label.get("name", "") if isinstance(label, dict) else str(label)
253
+ for label in issue["labels"]]
254
+ elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
255
+ labels = [label["name"] for label in issue["labels"]["nodes"]]
256
+
257
+ label_names = [label.lower() for label in labels]
258
+
259
+ # Check for extended state labels
260
+ for state, label_name in GitHubStateMapping.STATE_LABELS.items():
261
+ if label_name.lower() in label_names:
262
+ return state
263
+
264
+ return TicketState.OPEN
265
+
266
+ def _task_from_github_issue(self, issue: Dict[str, Any]) -> Task:
267
+ """Convert GitHub issue to universal Task."""
268
+ # Extract labels
269
+ labels = []
270
+ if "labels" in issue:
271
+ if isinstance(issue["labels"], list):
272
+ labels = [label.get("name", "") if isinstance(label, dict) else str(label)
273
+ for label in issue["labels"]]
274
+ elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
275
+ labels = [label["name"] for label in issue["labels"]["nodes"]]
276
+
277
+ # Extract state
278
+ state = self._extract_state_from_issue(issue)
279
+
280
+ # Extract priority
281
+ priority = self._get_priority_from_labels(labels)
282
+
283
+ # Extract assignee
284
+ assignee = None
285
+ if "assignees" in issue:
286
+ if isinstance(issue["assignees"], list) and issue["assignees"]:
287
+ assignee = issue["assignees"][0].get("login")
288
+ elif isinstance(issue["assignees"], dict) and "nodes" in issue["assignees"]:
289
+ nodes = issue["assignees"]["nodes"]
290
+ if nodes:
291
+ assignee = nodes[0].get("login")
292
+ elif "assignee" in issue and issue["assignee"]:
293
+ assignee = issue["assignee"].get("login")
294
+
295
+ # Extract parent epic (milestone)
296
+ parent_epic = None
297
+ if issue.get("milestone"):
298
+ parent_epic = str(issue["milestone"]["number"])
299
+
300
+ # Parse dates
301
+ created_at = None
302
+ if issue.get("created_at"):
303
+ created_at = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
304
+ elif issue.get("createdAt"):
305
+ created_at = datetime.fromisoformat(issue["createdAt"].replace("Z", "+00:00"))
306
+
307
+ updated_at = None
308
+ if issue.get("updated_at"):
309
+ updated_at = datetime.fromisoformat(issue["updated_at"].replace("Z", "+00:00"))
310
+ elif issue.get("updatedAt"):
311
+ updated_at = datetime.fromisoformat(issue["updatedAt"].replace("Z", "+00:00"))
312
+
313
+ # Build metadata
314
+ metadata = {
315
+ "github": {
316
+ "number": issue.get("number"),
317
+ "url": issue.get("url") or issue.get("html_url"),
318
+ "author": issue.get("user", {}).get("login") if "user" in issue else issue.get("author", {}).get("login"),
319
+ "labels": labels,
320
+ }
321
+ }
322
+
323
+ # Add projects v2 info if available
324
+ if "projectCards" in issue and issue["projectCards"].get("nodes"):
325
+ metadata["github"]["projects"] = [
326
+ {
327
+ "name": card["project"]["name"],
328
+ "column": card["column"]["name"],
329
+ "url": card["project"]["url"],
330
+ }
331
+ for card in issue["projectCards"]["nodes"]
332
+ ]
333
+
334
+ return Task(
335
+ id=str(issue["number"]),
336
+ title=issue["title"],
337
+ description=issue.get("body") or issue.get("bodyText"),
338
+ state=state,
339
+ priority=priority,
340
+ tags=labels,
341
+ parent_epic=parent_epic,
342
+ assignee=assignee,
343
+ created_at=created_at,
344
+ updated_at=updated_at,
345
+ metadata=metadata,
346
+ )
347
+
348
+ async def _ensure_label_exists(self, label_name: str, color: str = "0366d6") -> None:
349
+ """Ensure a label exists in the repository."""
350
+ if not self._labels_cache:
351
+ response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
352
+ response.raise_for_status()
353
+ self._labels_cache = response.json()
354
+
355
+ # Check if label exists
356
+ existing_labels = [label["name"].lower() for label in self._labels_cache]
357
+ if label_name.lower() not in existing_labels:
358
+ # Create the label
359
+ response = await self.client.post(
360
+ f"/repos/{self.owner}/{self.repo}/labels",
361
+ json={"name": label_name, "color": color}
362
+ )
363
+ if response.status_code == 201:
364
+ self._labels_cache.append(response.json())
365
+
366
+ async def _graphql_request(self, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
367
+ """Execute a GraphQL query."""
368
+ response = await self.client.post(
369
+ self.graphql_url,
370
+ json={"query": query, "variables": variables}
371
+ )
372
+ response.raise_for_status()
373
+
374
+ data = response.json()
375
+ if "errors" in data:
376
+ raise ValueError(f"GraphQL errors: {data['errors']}")
377
+
378
+ return data["data"]
379
+
380
+ async def create(self, ticket: Task) -> Task:
381
+ """Create a new GitHub issue."""
382
+ # Prepare labels
383
+ labels = ticket.tags.copy() if ticket.tags else []
384
+
385
+ # Add state label if needed
386
+ state_label = self._get_state_label(ticket.state)
387
+ if state_label:
388
+ labels.append(state_label)
389
+ await self._ensure_label_exists(state_label, "fbca04")
390
+
391
+ # Add priority label
392
+ priority_label = self._get_priority_label(ticket.priority)
393
+ labels.append(priority_label)
394
+ await self._ensure_label_exists(priority_label, "d73a4a")
395
+
396
+ # Ensure all labels exist
397
+ for label in labels:
398
+ await self._ensure_label_exists(label)
399
+
400
+ # Build issue data
401
+ issue_data = {
402
+ "title": ticket.title,
403
+ "body": ticket.description or "",
404
+ "labels": labels,
405
+ }
406
+
407
+ # Add assignee if specified
408
+ if ticket.assignee:
409
+ issue_data["assignees"] = [ticket.assignee]
410
+
411
+ # Add milestone if parent_epic is specified
412
+ if ticket.parent_epic:
413
+ try:
414
+ milestone_number = int(ticket.parent_epic)
415
+ issue_data["milestone"] = milestone_number
416
+ except ValueError:
417
+ # Try to find milestone by title
418
+ if not self._milestones_cache:
419
+ response = await self.client.get(
420
+ f"/repos/{self.owner}/{self.repo}/milestones"
421
+ )
422
+ response.raise_for_status()
423
+ self._milestones_cache = response.json()
424
+
425
+ for milestone in self._milestones_cache:
426
+ if milestone["title"] == ticket.parent_epic:
427
+ issue_data["milestone"] = milestone["number"]
428
+ break
429
+
430
+ # Create the issue
431
+ response = await self.client.post(
432
+ f"/repos/{self.owner}/{self.repo}/issues",
433
+ json=issue_data
434
+ )
435
+ response.raise_for_status()
436
+
437
+ created_issue = response.json()
438
+
439
+ # If state requires closing, close the issue
440
+ if ticket.state in [TicketState.DONE, TicketState.CLOSED]:
441
+ await self.client.patch(
442
+ f"/repos/{self.owner}/{self.repo}/issues/{created_issue['number']}",
443
+ json={"state": "closed"}
444
+ )
445
+ created_issue["state"] = "closed"
446
+
447
+ return self._task_from_github_issue(created_issue)
448
+
449
+ async def read(self, ticket_id: str) -> Optional[Task]:
450
+ """Read a GitHub issue by number."""
451
+ try:
452
+ issue_number = int(ticket_id)
453
+ except ValueError:
454
+ return None
455
+
456
+ try:
457
+ response = await self.client.get(
458
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
459
+ )
460
+ if response.status_code == 404:
461
+ return None
462
+ response.raise_for_status()
463
+
464
+ issue = response.json()
465
+ return self._task_from_github_issue(issue)
466
+ except httpx.HTTPError:
467
+ return None
468
+
469
+ async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[Task]:
470
+ """Update a GitHub issue."""
471
+ try:
472
+ issue_number = int(ticket_id)
473
+ except ValueError:
474
+ return None
475
+
476
+ # Get current issue to preserve labels
477
+ response = await self.client.get(
478
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
479
+ )
480
+ if response.status_code == 404:
481
+ return None
482
+ response.raise_for_status()
483
+
484
+ current_issue = response.json()
485
+ current_labels = [label["name"] for label in current_issue.get("labels", [])]
486
+
487
+ # Build update data
488
+ update_data = {}
489
+
490
+ if "title" in updates:
491
+ update_data["title"] = updates["title"]
492
+
493
+ if "description" in updates:
494
+ update_data["body"] = updates["description"]
495
+
496
+ # Handle state updates
497
+ if "state" in updates:
498
+ new_state = updates["state"]
499
+ if isinstance(new_state, str):
500
+ new_state = TicketState(new_state)
501
+
502
+ # Remove old state labels
503
+ labels_to_update = [
504
+ label for label in current_labels
505
+ if label.lower() not in [sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()]
506
+ ]
507
+
508
+ # Add new state label if needed
509
+ state_label = self._get_state_label(new_state)
510
+ if state_label:
511
+ await self._ensure_label_exists(state_label, "fbca04")
512
+ labels_to_update.append(state_label)
513
+
514
+ update_data["labels"] = labels_to_update
515
+
516
+ # Update issue state if needed
517
+ if new_state in [TicketState.DONE, TicketState.CLOSED]:
518
+ update_data["state"] = "closed"
519
+ else:
520
+ update_data["state"] = "open"
521
+
522
+ # Handle priority updates
523
+ if "priority" in updates:
524
+ new_priority = updates["priority"]
525
+ if isinstance(new_priority, str):
526
+ new_priority = Priority(new_priority)
527
+
528
+ # Remove old priority labels
529
+ labels_to_update = update_data.get("labels", current_labels)
530
+ all_priority_labels = []
531
+ for labels in GitHubStateMapping.PRIORITY_LABELS.values():
532
+ all_priority_labels.extend([l.lower() for l in labels])
533
+
534
+ labels_to_update = [
535
+ label for label in labels_to_update
536
+ if label.lower() not in all_priority_labels and not re.match(r'^P[0-3]$', label, re.IGNORECASE)
537
+ ]
538
+
539
+ # Add new priority label
540
+ priority_label = self._get_priority_label(new_priority)
541
+ await self._ensure_label_exists(priority_label, "d73a4a")
542
+ labels_to_update.append(priority_label)
543
+
544
+ update_data["labels"] = labels_to_update
545
+
546
+ # Handle assignee updates
547
+ if "assignee" in updates:
548
+ if updates["assignee"]:
549
+ update_data["assignees"] = [updates["assignee"]]
550
+ else:
551
+ update_data["assignees"] = []
552
+
553
+ # Handle tags updates
554
+ if "tags" in updates:
555
+ # Preserve state and priority labels
556
+ preserved_labels = []
557
+ for label in current_labels:
558
+ if label.lower() in [sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()]:
559
+ preserved_labels.append(label)
560
+ elif any(label.lower() in [pl.lower() for pl in labels]
561
+ for labels in GitHubStateMapping.PRIORITY_LABELS.values()):
562
+ preserved_labels.append(label)
563
+ elif re.match(r'^P[0-3]$', label, re.IGNORECASE):
564
+ preserved_labels.append(label)
565
+
566
+ # Add new tags
567
+ for tag in updates["tags"]:
568
+ await self._ensure_label_exists(tag)
569
+
570
+ update_data["labels"] = preserved_labels + updates["tags"]
571
+
572
+ # Apply updates
573
+ if update_data:
574
+ response = await self.client.patch(
575
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
576
+ json=update_data
577
+ )
578
+ response.raise_for_status()
579
+
580
+ updated_issue = response.json()
581
+ return self._task_from_github_issue(updated_issue)
582
+
583
+ return await self.read(ticket_id)
584
+
585
+ async def delete(self, ticket_id: str) -> bool:
586
+ """Delete (close) a GitHub issue."""
587
+ try:
588
+ issue_number = int(ticket_id)
589
+ except ValueError:
590
+ return False
591
+
592
+ try:
593
+ response = await self.client.patch(
594
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
595
+ json={"state": "closed", "state_reason": "not_planned"}
596
+ )
597
+ response.raise_for_status()
598
+ return True
599
+ except httpx.HTTPError:
600
+ return False
601
+
602
+ async def list(
603
+ self,
604
+ limit: int = 10,
605
+ offset: int = 0,
606
+ filters: Optional[Dict[str, Any]] = None
607
+ ) -> List[Task]:
608
+ """List GitHub issues with filters."""
609
+ # Build query parameters
610
+ params = {
611
+ "per_page": min(limit, 100), # GitHub max is 100
612
+ "page": (offset // limit) + 1 if limit > 0 else 1,
613
+ }
614
+
615
+ if filters:
616
+ # State filter
617
+ if "state" in filters:
618
+ state = filters["state"]
619
+ if isinstance(state, str):
620
+ state = TicketState(state)
621
+
622
+ if state in [TicketState.DONE, TicketState.CLOSED]:
623
+ params["state"] = "closed"
624
+ else:
625
+ params["state"] = "open"
626
+ # Add label filter for extended states
627
+ state_label = self._get_state_label(state)
628
+ if state_label:
629
+ params["labels"] = state_label
630
+
631
+ # Priority filter via labels
632
+ if "priority" in filters:
633
+ priority = filters["priority"]
634
+ if isinstance(priority, str):
635
+ priority = Priority(priority)
636
+ priority_label = self._get_priority_label(priority)
637
+
638
+ if "labels" in params:
639
+ params["labels"] += f",{priority_label}"
640
+ else:
641
+ params["labels"] = priority_label
642
+
643
+ # Assignee filter
644
+ if "assignee" in filters:
645
+ params["assignee"] = filters["assignee"]
646
+
647
+ # Milestone filter (parent_epic)
648
+ if "parent_epic" in filters:
649
+ params["milestone"] = filters["parent_epic"]
650
+
651
+ response = await self.client.get(
652
+ f"/repos/{self.owner}/{self.repo}/issues",
653
+ params=params
654
+ )
655
+ response.raise_for_status()
656
+
657
+ issues = response.json()
658
+
659
+ # Store rate limit info
660
+ self._rate_limit = {
661
+ "limit": response.headers.get("X-RateLimit-Limit"),
662
+ "remaining": response.headers.get("X-RateLimit-Remaining"),
663
+ "reset": response.headers.get("X-RateLimit-Reset"),
664
+ }
665
+
666
+ # Filter out pull requests (they appear as issues in the API)
667
+ issues = [issue for issue in issues if "pull_request" not in issue]
668
+
669
+ return [self._task_from_github_issue(issue) for issue in issues]
670
+
671
+ async def search(self, query: SearchQuery) -> List[Task]:
672
+ """Search GitHub issues using advanced search syntax."""
673
+ # Build GitHub search query
674
+ search_parts = [f"repo:{self.owner}/{self.repo}", "is:issue"]
675
+
676
+ # Text search
677
+ if query.query:
678
+ # Escape special characters for GitHub search
679
+ escaped_query = query.query.replace('"', '\\"')
680
+ search_parts.append(f'"{escaped_query}"')
681
+
682
+ # State filter
683
+ if query.state:
684
+ if query.state in [TicketState.DONE, TicketState.CLOSED]:
685
+ search_parts.append("is:closed")
686
+ else:
687
+ search_parts.append("is:open")
688
+ # Add label filter for extended states
689
+ state_label = self._get_state_label(query.state)
690
+ if state_label:
691
+ search_parts.append(f'label:"{state_label}"')
692
+
693
+ # Priority filter
694
+ if query.priority:
695
+ priority_label = self._get_priority_label(query.priority)
696
+ search_parts.append(f'label:"{priority_label}"')
697
+
698
+ # Assignee filter
699
+ if query.assignee:
700
+ search_parts.append(f"assignee:{query.assignee}")
701
+
702
+ # Tags filter
703
+ if query.tags:
704
+ for tag in query.tags:
705
+ search_parts.append(f'label:"{tag}"')
706
+
707
+ # Build final search query
708
+ github_query = " ".join(search_parts)
709
+
710
+ # Use GraphQL for better search capabilities
711
+ full_query = (GitHubGraphQLQueries.ISSUE_FRAGMENT +
712
+ GitHubGraphQLQueries.SEARCH_ISSUES)
713
+
714
+ variables = {
715
+ "query": github_query,
716
+ "first": min(query.limit, 100),
717
+ "after": None,
718
+ }
719
+
720
+ # Handle pagination for offset
721
+ if query.offset > 0:
722
+ # We need to paginate through to get to the offset
723
+ # This is inefficient but GitHub doesn't support direct offset
724
+ pages_to_skip = query.offset // 100
725
+ for _ in range(pages_to_skip):
726
+ temp_result = await self._graphql_request(full_query, variables)
727
+ page_info = temp_result["search"]["pageInfo"]
728
+ if page_info["hasNextPage"]:
729
+ variables["after"] = page_info["endCursor"]
730
+ else:
731
+ return [] # Offset beyond available results
732
+
733
+ result = await self._graphql_request(full_query, variables)
734
+
735
+ issues = []
736
+ for node in result["search"]["nodes"]:
737
+ if node: # Some nodes might be null
738
+ # Convert GraphQL format to REST format for consistency
739
+ rest_format = {
740
+ "number": node["number"],
741
+ "title": node["title"],
742
+ "body": node["body"],
743
+ "state": node["state"].lower(),
744
+ "created_at": node["createdAt"],
745
+ "updated_at": node["updatedAt"],
746
+ "html_url": node["url"],
747
+ "labels": node.get("labels", {}).get("nodes", []),
748
+ "milestone": node.get("milestone"),
749
+ "assignees": node.get("assignees", {}).get("nodes", []),
750
+ "author": node.get("author"),
751
+ }
752
+ issues.append(self._task_from_github_issue(rest_format))
753
+
754
+ return issues
755
+
756
+ async def transition_state(
757
+ self,
758
+ ticket_id: str,
759
+ target_state: TicketState
760
+ ) -> Optional[Task]:
761
+ """Transition GitHub issue to a new state."""
762
+ # Validate transition
763
+ if not await self.validate_transition(ticket_id, target_state):
764
+ return None
765
+
766
+ # Update state
767
+ return await self.update(ticket_id, {"state": target_state})
768
+
769
+ async def add_comment(self, comment: Comment) -> Comment:
770
+ """Add a comment to a GitHub issue."""
771
+ try:
772
+ issue_number = int(comment.ticket_id)
773
+ except ValueError:
774
+ raise ValueError(f"Invalid issue number: {comment.ticket_id}")
775
+
776
+ # Create comment
777
+ response = await self.client.post(
778
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
779
+ json={"body": comment.content}
780
+ )
781
+ response.raise_for_status()
782
+
783
+ created_comment = response.json()
784
+
785
+ return Comment(
786
+ id=str(created_comment["id"]),
787
+ ticket_id=comment.ticket_id,
788
+ author=created_comment["user"]["login"],
789
+ content=created_comment["body"],
790
+ created_at=datetime.fromisoformat(created_comment["created_at"].replace("Z", "+00:00")),
791
+ metadata={
792
+ "github": {
793
+ "id": created_comment["id"],
794
+ "url": created_comment["html_url"],
795
+ "author_avatar": created_comment["user"]["avatar_url"],
796
+ }
797
+ },
798
+ )
799
+
800
+ async def get_comments(
801
+ self,
802
+ ticket_id: str,
803
+ limit: int = 10,
804
+ offset: int = 0
805
+ ) -> List[Comment]:
806
+ """Get comments for a GitHub issue."""
807
+ try:
808
+ issue_number = int(ticket_id)
809
+ except ValueError:
810
+ return []
811
+
812
+ params = {
813
+ "per_page": min(limit, 100),
814
+ "page": (offset // limit) + 1 if limit > 0 else 1,
815
+ }
816
+
817
+ try:
818
+ response = await self.client.get(
819
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
820
+ params=params
821
+ )
822
+ response.raise_for_status()
823
+
824
+ comments = []
825
+ for comment_data in response.json():
826
+ comments.append(Comment(
827
+ id=str(comment_data["id"]),
828
+ ticket_id=ticket_id,
829
+ author=comment_data["user"]["login"],
830
+ content=comment_data["body"],
831
+ created_at=datetime.fromisoformat(comment_data["created_at"].replace("Z", "+00:00")),
832
+ metadata={
833
+ "github": {
834
+ "id": comment_data["id"],
835
+ "url": comment_data["html_url"],
836
+ "author_avatar": comment_data["user"]["avatar_url"],
837
+ }
838
+ },
839
+ ))
840
+
841
+ return comments
842
+ except httpx.HTTPError:
843
+ return []
844
+
845
+ async def get_rate_limit(self) -> Dict[str, Any]:
846
+ """Get current rate limit status."""
847
+ response = await self.client.get("/rate_limit")
848
+ response.raise_for_status()
849
+ return response.json()
850
+
851
+ async def create_milestone(self, epic: Epic) -> Epic:
852
+ """Create a GitHub milestone as an Epic."""
853
+ milestone_data = {
854
+ "title": epic.title,
855
+ "description": epic.description or "",
856
+ "state": "open" if epic.state != TicketState.CLOSED else "closed",
857
+ }
858
+
859
+ response = await self.client.post(
860
+ f"/repos/{self.owner}/{self.repo}/milestones",
861
+ json=milestone_data
862
+ )
863
+ response.raise_for_status()
864
+
865
+ created_milestone = response.json()
866
+
867
+ return Epic(
868
+ id=str(created_milestone["number"]),
869
+ title=created_milestone["title"],
870
+ description=created_milestone["description"],
871
+ state=TicketState.OPEN if created_milestone["state"] == "open" else TicketState.CLOSED,
872
+ created_at=datetime.fromisoformat(created_milestone["created_at"].replace("Z", "+00:00")),
873
+ updated_at=datetime.fromisoformat(created_milestone["updated_at"].replace("Z", "+00:00")),
874
+ metadata={
875
+ "github": {
876
+ "number": created_milestone["number"],
877
+ "url": created_milestone["html_url"],
878
+ "open_issues": created_milestone["open_issues"],
879
+ "closed_issues": created_milestone["closed_issues"],
880
+ }
881
+ },
882
+ )
883
+
884
+ async def get_milestone(self, milestone_number: int) -> Optional[Epic]:
885
+ """Get a GitHub milestone as an Epic."""
886
+ try:
887
+ response = await self.client.get(
888
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
889
+ )
890
+ if response.status_code == 404:
891
+ return None
892
+ response.raise_for_status()
893
+
894
+ milestone = response.json()
895
+
896
+ return Epic(
897
+ id=str(milestone["number"]),
898
+ title=milestone["title"],
899
+ description=milestone["description"],
900
+ state=TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED,
901
+ created_at=datetime.fromisoformat(milestone["created_at"].replace("Z", "+00:00")),
902
+ updated_at=datetime.fromisoformat(milestone["updated_at"].replace("Z", "+00:00")),
903
+ metadata={
904
+ "github": {
905
+ "number": milestone["number"],
906
+ "url": milestone["html_url"],
907
+ "open_issues": milestone["open_issues"],
908
+ "closed_issues": milestone["closed_issues"],
909
+ }
910
+ },
911
+ )
912
+ except httpx.HTTPError:
913
+ return None
914
+
915
+ async def list_milestones(
916
+ self,
917
+ state: str = "open",
918
+ limit: int = 10,
919
+ offset: int = 0
920
+ ) -> List[Epic]:
921
+ """List GitHub milestones as Epics."""
922
+ params = {
923
+ "state": state,
924
+ "per_page": min(limit, 100),
925
+ "page": (offset // limit) + 1 if limit > 0 else 1,
926
+ }
927
+
928
+ response = await self.client.get(
929
+ f"/repos/{self.owner}/{self.repo}/milestones",
930
+ params=params
931
+ )
932
+ response.raise_for_status()
933
+
934
+ epics = []
935
+ for milestone in response.json():
936
+ epics.append(Epic(
937
+ id=str(milestone["number"]),
938
+ title=milestone["title"],
939
+ description=milestone["description"],
940
+ state=TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED,
941
+ created_at=datetime.fromisoformat(milestone["created_at"].replace("Z", "+00:00")),
942
+ updated_at=datetime.fromisoformat(milestone["updated_at"].replace("Z", "+00:00")),
943
+ metadata={
944
+ "github": {
945
+ "number": milestone["number"],
946
+ "url": milestone["html_url"],
947
+ "open_issues": milestone["open_issues"],
948
+ "closed_issues": milestone["closed_issues"],
949
+ }
950
+ },
951
+ ))
952
+
953
+ return epics
954
+
955
+ async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
956
+ """Link an issue to a pull request using keywords."""
957
+ # This is typically done through PR description keywords like "fixes #123"
958
+ # We can add a comment to track the link
959
+ comment = f"Linked to PR #{pr_number}"
960
+
961
+ response = await self.client.post(
962
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
963
+ json={"body": comment}
964
+ )
965
+
966
+ return response.status_code == 201
967
+
968
+ async def close(self) -> None:
969
+ """Close the HTTP client connection."""
970
+ await self.client.aclose()
971
+
972
+
973
+ # Register the adapter
974
+ AdapterRegistry.register("github", GitHubAdapter)