elizaos-plugin-github 2.0.0a4__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.
@@ -0,0 +1,716 @@
1
+ import base64
2
+ import logging
3
+ from datetime import UTC, datetime
4
+
5
+ from github import Auth, Github, GithubException
6
+ from github.Issue import Issue
7
+ from github.IssueComment import IssueComment
8
+ from github.PullRequest import PullRequest
9
+ from github.PullRequestReview import PullRequestReview
10
+ from github.Repository import Repository
11
+
12
+ from elizaos_plugin_github.config import GitHubConfig
13
+ from elizaos_plugin_github.error import (
14
+ BranchExistsError,
15
+ BranchNotFoundError,
16
+ ClientNotInitializedError,
17
+ FileNotFoundError,
18
+ GitHubApiError,
19
+ IssueNotFoundError,
20
+ MergeConflictError,
21
+ PermissionDeniedError,
22
+ PullRequestNotFoundError,
23
+ RateLimitedError,
24
+ RepositoryNotFoundError,
25
+ )
26
+ from elizaos_plugin_github.types import (
27
+ CreateBranchParams,
28
+ CreateCommentParams,
29
+ CreateCommitParams,
30
+ CreateIssueParams,
31
+ CreatePullRequestParams,
32
+ CreateReviewParams,
33
+ GitHubBranch,
34
+ GitHubBranchRef,
35
+ GitHubComment,
36
+ GitHubCommit,
37
+ GitHubCommitAuthor,
38
+ GitHubDirectoryEntry,
39
+ GitHubFileContent,
40
+ GitHubIssue,
41
+ GitHubLabel,
42
+ GitHubMilestone,
43
+ GitHubPullRequest,
44
+ GitHubRepository,
45
+ GitHubReview,
46
+ GitHubUser,
47
+ IssueState,
48
+ IssueStateReason,
49
+ ListIssuesParams,
50
+ ListPullRequestsParams,
51
+ MergeableState,
52
+ MergePullRequestParams,
53
+ PullRequestState,
54
+ RepositoryRef,
55
+ ReviewState,
56
+ UpdateIssueParams,
57
+ )
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ class GitHubService:
63
+ def __init__(self, config: GitHubConfig) -> None:
64
+ self._config = config
65
+ self._client: Github | None = None
66
+
67
+ @property
68
+ def config(self) -> GitHubConfig:
69
+ return self._config
70
+
71
+ def _get_client(self) -> Github:
72
+ if self._client is None:
73
+ raise ClientNotInitializedError()
74
+ return self._client
75
+
76
+ async def start(self) -> None:
77
+ logger.info("Starting GitHub service...")
78
+
79
+ self._config.validate_all()
80
+
81
+ auth = Auth.Token(self._config.api_token)
82
+ self._client = Github(auth=auth)
83
+
84
+ try:
85
+ user = self._client.get_user()
86
+ logger.info(f"GitHub service started - authenticated as {user.login}")
87
+ except GithubException as e:
88
+ raise self._map_exception(e, "", "") from e
89
+
90
+ async def stop(self) -> None:
91
+ logger.info("Stopping GitHub service...")
92
+ if self._client:
93
+ self._client.close()
94
+ self._client = None
95
+ logger.info("GitHub service stopped")
96
+
97
+ async def get_repository(self, params: RepositoryRef) -> GitHubRepository:
98
+ client = self._get_client()
99
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
100
+
101
+ try:
102
+ repo = client.get_repo(f"{owner}/{repo_name}")
103
+ return self._map_repository(repo)
104
+ except GithubException as e:
105
+ raise self._map_exception(e, owner, repo_name) from e
106
+
107
+ async def create_issue(self, params: CreateIssueParams) -> GitHubIssue:
108
+ client = self._get_client()
109
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
110
+
111
+ try:
112
+ repo = client.get_repo(f"{owner}/{repo_name}")
113
+ issue = repo.create_issue(
114
+ title=params.title,
115
+ body=params.body or "",
116
+ assignees=params.assignees,
117
+ labels=params.labels,
118
+ milestone=repo.get_milestone(params.milestone) if params.milestone else None,
119
+ )
120
+ return self._map_issue(issue)
121
+ except GithubException as e:
122
+ raise self._map_exception(e, owner, repo_name) from e
123
+
124
+ async def get_issue(self, owner: str, repo: str, issue_number: int) -> GitHubIssue:
125
+ client = self._get_client()
126
+ owner, repo_name = self._resolve_repo_ref(owner, repo)
127
+
128
+ try:
129
+ repo_obj = client.get_repo(f"{owner}/{repo_name}")
130
+ issue = repo_obj.get_issue(issue_number)
131
+ return self._map_issue(issue)
132
+ except GithubException as e:
133
+ exc = self._map_exception(e, owner, repo_name)
134
+ if isinstance(exc, RepositoryNotFoundError):
135
+ raise IssueNotFoundError(issue_number, owner, repo_name) from e
136
+ raise exc from e
137
+
138
+ async def update_issue(self, params: UpdateIssueParams) -> GitHubIssue:
139
+ client = self._get_client()
140
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
141
+
142
+ try:
143
+ repo = client.get_repo(f"{owner}/{repo_name}")
144
+ issue = repo.get_issue(params.issue_number)
145
+
146
+ kwargs: dict[str, object] = {}
147
+ if params.title is not None:
148
+ kwargs["title"] = params.title
149
+ if params.body is not None:
150
+ kwargs["body"] = params.body
151
+ if params.state is not None:
152
+ kwargs["state"] = params.state.value
153
+ if params.assignees is not None:
154
+ kwargs["assignees"] = params.assignees
155
+ if params.labels is not None:
156
+ kwargs["labels"] = params.labels
157
+ if params.milestone is not None:
158
+ kwargs["milestone"] = repo.get_milestone(params.milestone)
159
+
160
+ issue.edit(**kwargs)
161
+ return self._map_issue(issue)
162
+ except GithubException as e:
163
+ raise self._map_exception(e, owner, repo_name) from e
164
+
165
+ async def list_issues(self, params: ListIssuesParams) -> list[GitHubIssue]:
166
+ client = self._get_client()
167
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
168
+
169
+ try:
170
+ repo = client.get_repo(f"{owner}/{repo_name}")
171
+ issues = repo.get_issues(
172
+ state=params.state,
173
+ sort=params.sort,
174
+ direction=params.direction,
175
+ labels=params.labels.split(",") if params.labels else [],
176
+ assignee=params.assignee or "none",
177
+ )
178
+
179
+ result: list[GitHubIssue] = []
180
+ start = (params.page - 1) * params.per_page
181
+ end = start + params.per_page
182
+
183
+ for i, issue in enumerate(issues):
184
+ if i < start:
185
+ continue
186
+ if i >= end:
187
+ break
188
+ if issue.pull_request is None:
189
+ result.append(self._map_issue(issue))
190
+
191
+ return result
192
+ except GithubException as e:
193
+ raise self._map_exception(e, owner, repo_name) from e
194
+
195
+ async def create_pull_request(self, params: CreatePullRequestParams) -> GitHubPullRequest:
196
+ client = self._get_client()
197
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
198
+
199
+ try:
200
+ repo = client.get_repo(f"{owner}/{repo_name}")
201
+ pr = repo.create_pull(
202
+ title=params.title,
203
+ body=params.body or "",
204
+ head=params.head,
205
+ base=params.base,
206
+ draft=params.draft,
207
+ maintainer_can_modify=params.maintainer_can_modify,
208
+ )
209
+ return self._map_pull_request(pr)
210
+ except GithubException as e:
211
+ raise self._map_exception(e, owner, repo_name) from e
212
+
213
+ async def get_pull_request(self, owner: str, repo: str, pull_number: int) -> GitHubPullRequest:
214
+ client = self._get_client()
215
+ owner, repo_name = self._resolve_repo_ref(owner, repo)
216
+
217
+ try:
218
+ repo_obj = client.get_repo(f"{owner}/{repo_name}")
219
+ pr = repo_obj.get_pull(pull_number)
220
+ return self._map_pull_request(pr)
221
+ except GithubException as e:
222
+ exc = self._map_exception(e, owner, repo_name)
223
+ if isinstance(exc, RepositoryNotFoundError):
224
+ raise PullRequestNotFoundError(pull_number, owner, repo_name) from e
225
+ raise exc from e
226
+
227
+ async def list_pull_requests(self, params: ListPullRequestsParams) -> list[GitHubPullRequest]:
228
+ client = self._get_client()
229
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
230
+
231
+ try:
232
+ repo = client.get_repo(f"{owner}/{repo_name}")
233
+ prs = repo.get_pulls(
234
+ state=params.state,
235
+ sort=params.sort,
236
+ direction=params.direction,
237
+ head=params.head,
238
+ base=params.base,
239
+ )
240
+
241
+ result: list[GitHubPullRequest] = []
242
+ start = (params.page - 1) * params.per_page
243
+ end = start + params.per_page
244
+
245
+ for i, pr in enumerate(prs):
246
+ if i < start:
247
+ continue
248
+ if i >= end:
249
+ break
250
+ result.append(self._map_pull_request(pr))
251
+
252
+ return result
253
+ except GithubException as e:
254
+ raise self._map_exception(e, owner, repo_name) from e
255
+
256
+ async def merge_pull_request(self, params: MergePullRequestParams) -> tuple[str, bool, str]:
257
+ client = self._get_client()
258
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
259
+
260
+ try:
261
+ repo = client.get_repo(f"{owner}/{repo_name}")
262
+ pr = repo.get_pull(params.pull_number)
263
+
264
+ result = pr.merge(
265
+ commit_title=params.commit_title,
266
+ commit_message=params.commit_message,
267
+ merge_method=params.merge_method,
268
+ sha=params.sha,
269
+ )
270
+
271
+ return (result.sha, result.merged, result.message)
272
+ except GithubException as e:
273
+ exc = self._map_exception(e, owner, repo_name)
274
+ if isinstance(exc, GitHubApiError) and exc.status == 405:
275
+ raise MergeConflictError(params.pull_number, owner, repo_name) from e
276
+ raise exc from e
277
+
278
+ async def create_review(self, params: CreateReviewParams) -> GitHubReview:
279
+ client = self._get_client()
280
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
281
+
282
+ try:
283
+ repo = client.get_repo(f"{owner}/{repo_name}")
284
+ pr = repo.get_pull(params.pull_number)
285
+
286
+ review = pr.create_review(
287
+ body=params.body,
288
+ event=params.event.value,
289
+ commit=repo.get_commit(params.commit_id) if params.commit_id else None,
290
+ comments=[
291
+ {
292
+ "path": c.path,
293
+ "line": c.line,
294
+ "body": c.body,
295
+ "side": c.side,
296
+ }
297
+ for c in params.comments
298
+ ],
299
+ )
300
+
301
+ return self._map_review(review)
302
+ except GithubException as e:
303
+ raise self._map_exception(e, owner, repo_name) from e
304
+
305
+ async def create_comment(self, params: CreateCommentParams) -> GitHubComment:
306
+ client = self._get_client()
307
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
308
+
309
+ try:
310
+ repo = client.get_repo(f"{owner}/{repo_name}")
311
+ issue = repo.get_issue(params.issue_number)
312
+ comment = issue.create_comment(params.body)
313
+ return self._map_comment(comment)
314
+ except GithubException as e:
315
+ raise self._map_exception(e, owner, repo_name) from e
316
+
317
+ # ===========================================================================
318
+ # Branch Operations
319
+ # ===========================================================================
320
+
321
+ async def create_branch(self, params: CreateBranchParams) -> GitHubBranch:
322
+ """Create a new branch."""
323
+ client = self._get_client()
324
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
325
+
326
+ try:
327
+ repo = client.get_repo(f"{owner}/{repo_name}")
328
+
329
+ try:
330
+ ref = repo.get_git_ref(f"heads/{params.from_ref}")
331
+ sha = ref.object.sha
332
+ except GithubException:
333
+ sha = params.from_ref
334
+
335
+ repo.create_git_ref(f"refs/heads/{params.branch_name}", sha)
336
+
337
+ return GitHubBranch(
338
+ name=params.branch_name,
339
+ sha=sha,
340
+ protected=False,
341
+ )
342
+ except GithubException as e:
343
+ exc = self._map_exception(e, owner, repo_name)
344
+ if "already exists" in str(e):
345
+ raise BranchExistsError(params.branch_name, owner, repo_name) from e
346
+ raise exc from e
347
+
348
+ async def delete_branch(self, owner: str, repo: str, branch_name: str) -> None:
349
+ client = self._get_client()
350
+ owner, repo_name = self._resolve_repo_ref(owner, repo)
351
+
352
+ try:
353
+ repo_obj = client.get_repo(f"{owner}/{repo_name}")
354
+ ref = repo_obj.get_git_ref(f"heads/{branch_name}")
355
+ ref.delete()
356
+ except GithubException as e:
357
+ exc = self._map_exception(e, owner, repo_name)
358
+ if isinstance(exc, RepositoryNotFoundError):
359
+ raise BranchNotFoundError(branch_name, owner, repo_name) from e
360
+ raise exc from e
361
+
362
+ async def list_branches(
363
+ self, owner: str, repo: str, per_page: int = 30, page: int = 1
364
+ ) -> list[GitHubBranch]:
365
+ client = self._get_client()
366
+ owner, repo_name = self._resolve_repo_ref(owner, repo)
367
+
368
+ try:
369
+ repo_obj = client.get_repo(f"{owner}/{repo_name}")
370
+ branches = repo_obj.get_branches()
371
+
372
+ result: list[GitHubBranch] = []
373
+ start = (page - 1) * per_page
374
+ end = start + per_page
375
+
376
+ for i, branch in enumerate(branches):
377
+ if i < start:
378
+ continue
379
+ if i >= end:
380
+ break
381
+ result.append(
382
+ GitHubBranch(
383
+ name=branch.name,
384
+ sha=branch.commit.sha,
385
+ protected=branch.protected,
386
+ )
387
+ )
388
+
389
+ return result
390
+ except GithubException as e:
391
+ raise self._map_exception(e, owner, repo_name) from e
392
+
393
+ async def get_file(
394
+ self, owner: str, repo: str, path: str, branch: str | None = None
395
+ ) -> GitHubFileContent:
396
+ client = self._get_client()
397
+ owner, repo_name = self._resolve_repo_ref(owner, repo)
398
+
399
+ try:
400
+ repo_obj = client.get_repo(f"{owner}/{repo_name}")
401
+ content = repo_obj.get_contents(path, ref=branch or self._config.branch)
402
+
403
+ if isinstance(content, list):
404
+ raise FileNotFoundError(f"{path} is a directory, not a file", owner, repo_name)
405
+
406
+ decoded_content = ""
407
+ if content.content:
408
+ decoded_content = base64.b64decode(content.content).decode("utf-8")
409
+
410
+ return GitHubFileContent(
411
+ name=content.name,
412
+ path=content.path,
413
+ content=decoded_content,
414
+ sha=content.sha,
415
+ size=content.size,
416
+ type="file",
417
+ encoding=content.encoding or "base64",
418
+ html_url=content.html_url,
419
+ download_url=content.download_url,
420
+ )
421
+ except GithubException as e:
422
+ exc = self._map_exception(e, owner, repo_name)
423
+ if isinstance(exc, RepositoryNotFoundError):
424
+ raise FileNotFoundError(path, owner, repo_name) from e
425
+ raise exc from e
426
+
427
+ async def list_directory(
428
+ self, owner: str, repo: str, path: str, branch: str | None = None
429
+ ) -> list[GitHubDirectoryEntry]:
430
+ """List directory contents."""
431
+ client = self._get_client()
432
+ owner, repo_name = self._resolve_repo_ref(owner, repo)
433
+
434
+ try:
435
+ repo_obj = client.get_repo(f"{owner}/{repo_name}")
436
+ contents = repo_obj.get_contents(path, ref=branch or self._config.branch)
437
+
438
+ if not isinstance(contents, list):
439
+ raise FileNotFoundError(f"{path} is a file, not a directory", owner, repo_name)
440
+
441
+ return [
442
+ GitHubDirectoryEntry(
443
+ name=c.name,
444
+ path=c.path,
445
+ sha=c.sha,
446
+ size=c.size,
447
+ type=c.type,
448
+ html_url=c.html_url,
449
+ download_url=c.download_url,
450
+ )
451
+ for c in contents
452
+ ]
453
+ except GithubException as e:
454
+ raise self._map_exception(e, owner, repo_name) from e
455
+
456
+ async def create_commit(self, params: CreateCommitParams) -> GitHubCommit:
457
+ client = self._get_client()
458
+ owner, repo_name = self._resolve_repo_ref(params.owner, params.repo)
459
+
460
+ try:
461
+ repo = client.get_repo(f"{owner}/{repo_name}")
462
+
463
+ ref = repo.get_git_ref(f"heads/{params.branch}")
464
+ parent_sha = params.parent_sha or ref.object.sha
465
+
466
+ parent_commit = repo.get_git_commit(parent_sha)
467
+
468
+ tree_elements = []
469
+ for file in params.files:
470
+ if file.operation == "delete":
471
+ continue
472
+
473
+ content = file.content
474
+ if file.encoding == "base64":
475
+ blob = repo.create_git_blob(content, "base64")
476
+ else:
477
+ blob = repo.create_git_blob(content, "utf-8")
478
+
479
+ tree_elements.append(
480
+ {
481
+ "path": file.path,
482
+ "mode": "100644",
483
+ "type": "blob",
484
+ "sha": blob.sha,
485
+ }
486
+ )
487
+
488
+ # Create tree
489
+ new_tree = repo.create_git_tree(tree_elements, parent_commit.tree)
490
+
491
+ # Create commit
492
+ author_name = params.author_name or client.get_user().name or "elizaos"
493
+ author_email = params.author_email or f"{author_name}@users.noreply.github.com"
494
+
495
+ commit = repo.create_git_commit(
496
+ message=params.message,
497
+ tree=new_tree,
498
+ parents=[parent_commit],
499
+ )
500
+
501
+ ref.edit(commit.sha)
502
+
503
+ return GitHubCommit(
504
+ sha=commit.sha,
505
+ message=commit.message,
506
+ author=GitHubCommitAuthor(
507
+ name=author_name,
508
+ email=author_email,
509
+ date=datetime.now(UTC).isoformat(),
510
+ ),
511
+ committer=GitHubCommitAuthor(
512
+ name=author_name,
513
+ email=author_email,
514
+ date=datetime.now(UTC).isoformat(),
515
+ ),
516
+ timestamp=datetime.now(UTC).isoformat(),
517
+ html_url=commit.html_url,
518
+ parents=[parent_sha],
519
+ )
520
+ except GithubException as e:
521
+ raise self._map_exception(e, owner, repo_name) from e
522
+
523
+ async def get_authenticated_user(self) -> GitHubUser:
524
+ client = self._get_client()
525
+ user = client.get_user()
526
+ return self._map_user_from_named_user(user)
527
+
528
+ def _resolve_repo_ref(self, owner: str | None, repo: str | None) -> tuple[str, str]:
529
+ return self._config.get_repository_ref(owner, repo)
530
+
531
+ def _map_exception(self, e: GithubException, owner: str, repo: str) -> Exception:
532
+ status = e.status
533
+
534
+ if status == 401:
535
+ return PermissionDeniedError("Invalid or missing authentication token")
536
+
537
+ if status == 403:
538
+ # Check for rate limiting
539
+ rate_limit = e.headers.get("X-RateLimit-Remaining")
540
+ if rate_limit == "0":
541
+ reset = e.headers.get("X-RateLimit-Reset")
542
+ if reset:
543
+ reset_time = datetime.fromtimestamp(int(reset), tz=UTC)
544
+ retry_after = int((reset_time - datetime.now(UTC)).total_seconds() * 1000)
545
+ return RateLimitedError(max(retry_after, 0), 0, reset_time)
546
+ return PermissionDeniedError(str(e.data))
547
+
548
+ if status == 404:
549
+ return RepositoryNotFoundError(owner, repo)
550
+
551
+ if status == 409:
552
+ message = str(e.data) if e.data else ""
553
+ if "merge" in message.lower():
554
+ return MergeConflictError(0, owner, repo)
555
+ if "already exists" in message.lower():
556
+ return BranchExistsError("unknown", owner, repo)
557
+
558
+ if status == 422:
559
+ return GitHubApiError(status, str(e.data))
560
+
561
+ return GitHubApiError(status, str(e.data))
562
+
563
+ def _map_repository(self, repo: Repository) -> GitHubRepository:
564
+ return GitHubRepository(
565
+ id=repo.id,
566
+ name=repo.name,
567
+ full_name=repo.full_name,
568
+ owner=self._map_user_from_named_user(repo.owner),
569
+ description=repo.description,
570
+ private=repo.private,
571
+ fork=repo.fork,
572
+ default_branch=repo.default_branch,
573
+ language=repo.language,
574
+ stargazers_count=repo.stargazers_count,
575
+ forks_count=repo.forks_count,
576
+ open_issues_count=repo.open_issues_count,
577
+ watchers_count=repo.watchers_count,
578
+ html_url=repo.html_url,
579
+ clone_url=repo.clone_url,
580
+ ssh_url=repo.ssh_url,
581
+ created_at=repo.created_at.isoformat() if repo.created_at else "",
582
+ updated_at=repo.updated_at.isoformat() if repo.updated_at else "",
583
+ pushed_at=repo.pushed_at.isoformat() if repo.pushed_at else "",
584
+ topics=repo.topics or [],
585
+ license=None, # Would need mapping
586
+ )
587
+
588
+ def _map_user_from_named_user(self, user: object) -> GitHubUser:
589
+ return GitHubUser(
590
+ id=user.id, # type: ignore
591
+ login=user.login, # type: ignore
592
+ name=getattr(user, "name", None),
593
+ avatar_url=user.avatar_url, # type: ignore
594
+ html_url=user.html_url, # type: ignore
595
+ type=user.type, # type: ignore
596
+ )
597
+
598
+ def _map_issue(self, issue: Issue) -> GitHubIssue:
599
+ return GitHubIssue(
600
+ number=issue.number,
601
+ title=issue.title,
602
+ body=issue.body,
603
+ state=IssueState(issue.state),
604
+ state_reason=IssueStateReason(issue.state_reason) if issue.state_reason else None,
605
+ user=self._map_user_from_named_user(issue.user),
606
+ assignees=[self._map_user_from_named_user(a) for a in issue.assignees],
607
+ labels=[
608
+ GitHubLabel(
609
+ id=label.id,
610
+ name=label.name,
611
+ color=label.color,
612
+ description=label.description,
613
+ default=label.default,
614
+ )
615
+ for label in issue.labels
616
+ ],
617
+ milestone=(
618
+ GitHubMilestone(
619
+ number=issue.milestone.number,
620
+ title=issue.milestone.title,
621
+ description=issue.milestone.description,
622
+ state=issue.milestone.state,
623
+ due_on=issue.milestone.due_on.isoformat() if issue.milestone.due_on else None,
624
+ created_at=issue.milestone.created_at.isoformat(),
625
+ updated_at=issue.milestone.updated_at.isoformat(),
626
+ closed_at=(
627
+ issue.milestone.closed_at.isoformat() if issue.milestone.closed_at else None
628
+ ),
629
+ open_issues=issue.milestone.open_issues,
630
+ closed_issues=issue.milestone.closed_issues,
631
+ )
632
+ if issue.milestone
633
+ else None
634
+ ),
635
+ created_at=issue.created_at.isoformat(),
636
+ updated_at=issue.updated_at.isoformat(),
637
+ closed_at=issue.closed_at.isoformat() if issue.closed_at else None,
638
+ html_url=issue.html_url,
639
+ comments=issue.comments,
640
+ is_pull_request=issue.pull_request is not None,
641
+ )
642
+
643
+ def _map_pull_request(self, pr: PullRequest) -> GitHubPullRequest:
644
+ """Map pull request to type."""
645
+ return GitHubPullRequest(
646
+ number=pr.number,
647
+ title=pr.title,
648
+ body=pr.body,
649
+ state=PullRequestState(pr.state),
650
+ draft=pr.draft,
651
+ merged=pr.merged,
652
+ mergeable=pr.mergeable,
653
+ mergeable_state=MergeableState(pr.mergeable_state)
654
+ if pr.mergeable_state
655
+ else MergeableState.UNKNOWN,
656
+ user=self._map_user_from_named_user(pr.user),
657
+ head=GitHubBranchRef(
658
+ ref=pr.head.ref,
659
+ label=pr.head.label,
660
+ sha=pr.head.sha,
661
+ repo=RepositoryRef(owner=pr.head.repo.owner.login, repo=pr.head.repo.name)
662
+ if pr.head.repo
663
+ else None,
664
+ ),
665
+ base=GitHubBranchRef(
666
+ ref=pr.base.ref,
667
+ label=pr.base.label,
668
+ sha=pr.base.sha,
669
+ repo=RepositoryRef(owner=pr.base.repo.owner.login, repo=pr.base.repo.name)
670
+ if pr.base.repo
671
+ else None,
672
+ ),
673
+ assignees=[self._map_user_from_named_user(a) for a in pr.assignees],
674
+ requested_reviewers=[self._map_user_from_named_user(r) for r in pr.requested_reviewers],
675
+ labels=[
676
+ GitHubLabel(
677
+ id=label.id,
678
+ name=label.name,
679
+ color=label.color,
680
+ description=label.description,
681
+ default=label.default,
682
+ )
683
+ for label in pr.labels
684
+ ],
685
+ milestone=None, # Would need full mapping
686
+ created_at=pr.created_at.isoformat(),
687
+ updated_at=pr.updated_at.isoformat(),
688
+ closed_at=pr.closed_at.isoformat() if pr.closed_at else None,
689
+ merged_at=pr.merged_at.isoformat() if pr.merged_at else None,
690
+ html_url=pr.html_url,
691
+ commits=pr.commits,
692
+ additions=pr.additions,
693
+ deletions=pr.deletions,
694
+ changed_files=pr.changed_files,
695
+ )
696
+
697
+ def _map_review(self, review: PullRequestReview) -> GitHubReview:
698
+ return GitHubReview(
699
+ id=review.id,
700
+ user=self._map_user_from_named_user(review.user),
701
+ body=review.body,
702
+ state=ReviewState(review.state),
703
+ commit_id=review.commit_id,
704
+ html_url=review.html_url,
705
+ submitted_at=review.submitted_at.isoformat() if review.submitted_at else None,
706
+ )
707
+
708
+ def _map_comment(self, comment: IssueComment) -> GitHubComment:
709
+ return GitHubComment(
710
+ id=comment.id,
711
+ body=comment.body,
712
+ user=self._map_user_from_named_user(comment.user),
713
+ created_at=comment.created_at.isoformat(),
714
+ updated_at=comment.updated_at.isoformat(),
715
+ html_url=comment.html_url,
716
+ )