quickcall-integrations 0.3.9__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_server/api_clients/github_client.py +1989 -543
- mcp_server/resources/github_resources.py +124 -0
- mcp_server/tools/github_tools.py +635 -1
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.5.0.dist-info}/METADATA +46 -2
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.5.0.dist-info}/RECORD +7 -7
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.5.0.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.5.0.dist-info}/entry_points.txt +0 -0
|
@@ -417,415 +417,359 @@ class GitHubClient:
|
|
|
417
417
|
html_url=pr.html_url,
|
|
418
418
|
)
|
|
419
419
|
|
|
420
|
-
|
|
421
|
-
# Commit Operations
|
|
422
|
-
# ========================================================================
|
|
423
|
-
|
|
424
|
-
def list_commits(
|
|
420
|
+
def create_pr(
|
|
425
421
|
self,
|
|
422
|
+
title: str,
|
|
423
|
+
head: str,
|
|
424
|
+
base: str,
|
|
425
|
+
body: Optional[str] = None,
|
|
426
|
+
draft: bool = False,
|
|
426
427
|
owner: Optional[str] = None,
|
|
427
428
|
repo: Optional[str] = None,
|
|
428
|
-
|
|
429
|
-
author: Optional[str] = None,
|
|
430
|
-
since: Optional[str] = None,
|
|
431
|
-
limit: int = 20,
|
|
432
|
-
detail_level: str = "summary",
|
|
433
|
-
) -> List[Commit] | List[CommitSummary]:
|
|
429
|
+
) -> PullRequest:
|
|
434
430
|
"""
|
|
435
|
-
|
|
431
|
+
Create a new pull request.
|
|
436
432
|
|
|
437
433
|
Args:
|
|
434
|
+
title: PR title
|
|
435
|
+
head: Branch containing changes (e.g., "feature-branch")
|
|
436
|
+
base: Branch to merge into (e.g., "main")
|
|
437
|
+
body: PR description
|
|
438
|
+
draft: Create as draft PR
|
|
438
439
|
owner: Repository owner
|
|
439
440
|
repo: Repository name
|
|
440
|
-
sha: Branch name or commit SHA to start from
|
|
441
|
-
author: Filter by author username
|
|
442
|
-
since: ISO datetime - only commits after this date
|
|
443
|
-
limit: Maximum commits to return
|
|
444
|
-
detail_level: 'summary' for minimal fields, 'full' for all fields
|
|
445
441
|
|
|
446
442
|
Returns:
|
|
447
|
-
|
|
443
|
+
Created PullRequest
|
|
448
444
|
"""
|
|
449
445
|
gh_repo = self._get_repo(owner, repo)
|
|
446
|
+
pr = gh_repo.create_pull(
|
|
447
|
+
title=title,
|
|
448
|
+
head=head,
|
|
449
|
+
base=base,
|
|
450
|
+
body=body or "",
|
|
451
|
+
draft=draft,
|
|
452
|
+
)
|
|
453
|
+
return self._convert_pr(pr)
|
|
450
454
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
455
|
+
def update_pr(
|
|
456
|
+
self,
|
|
457
|
+
pr_number: int,
|
|
458
|
+
title: Optional[str] = None,
|
|
459
|
+
body: Optional[str] = None,
|
|
460
|
+
state: Optional[str] = None,
|
|
461
|
+
base: Optional[str] = None,
|
|
462
|
+
owner: Optional[str] = None,
|
|
463
|
+
repo: Optional[str] = None,
|
|
464
|
+
) -> PullRequest:
|
|
465
|
+
"""
|
|
466
|
+
Update an existing pull request.
|
|
456
467
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
468
|
+
Args:
|
|
469
|
+
pr_number: PR number
|
|
470
|
+
title: New title
|
|
471
|
+
body: New description
|
|
472
|
+
state: New state ('open' or 'closed')
|
|
473
|
+
base: New base branch
|
|
474
|
+
owner: Repository owner
|
|
475
|
+
repo: Repository name
|
|
461
476
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
commit_author = commit.commit.author.name
|
|
477
|
+
Returns:
|
|
478
|
+
Updated PullRequest
|
|
479
|
+
"""
|
|
480
|
+
gh_repo = self._get_repo(owner, repo)
|
|
481
|
+
pr = gh_repo.get_pull(pr_number)
|
|
468
482
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
483
|
+
kwargs = {}
|
|
484
|
+
if title is not None:
|
|
485
|
+
kwargs["title"] = title
|
|
486
|
+
if body is not None:
|
|
487
|
+
kwargs["body"] = body
|
|
488
|
+
if state is not None:
|
|
489
|
+
kwargs["state"] = state
|
|
490
|
+
if base is not None:
|
|
491
|
+
kwargs["base"] = base
|
|
472
492
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
Commit(
|
|
476
|
-
sha=commit.sha,
|
|
477
|
-
message=commit.commit.message,
|
|
478
|
-
author=commit_author,
|
|
479
|
-
date=commit.commit.author.date,
|
|
480
|
-
html_url=commit.html_url,
|
|
481
|
-
)
|
|
482
|
-
)
|
|
483
|
-
else:
|
|
484
|
-
# Summary: just first line of message
|
|
485
|
-
message_title = commit.commit.message.split("\n")[0][:100]
|
|
486
|
-
commits.append(
|
|
487
|
-
CommitSummary(
|
|
488
|
-
sha=commit.sha[:7], # Short SHA for summary
|
|
489
|
-
message_title=message_title,
|
|
490
|
-
author=commit_author,
|
|
491
|
-
date=commit.commit.author.date,
|
|
492
|
-
html_url=commit.html_url,
|
|
493
|
-
)
|
|
494
|
-
)
|
|
493
|
+
if kwargs:
|
|
494
|
+
pr.edit(**kwargs)
|
|
495
495
|
|
|
496
|
-
return
|
|
496
|
+
return self._convert_pr(pr)
|
|
497
497
|
|
|
498
|
-
def
|
|
498
|
+
def merge_pr(
|
|
499
499
|
self,
|
|
500
|
-
|
|
500
|
+
pr_number: int,
|
|
501
|
+
commit_title: Optional[str] = None,
|
|
502
|
+
commit_message: Optional[str] = None,
|
|
503
|
+
merge_method: str = "merge",
|
|
501
504
|
owner: Optional[str] = None,
|
|
502
505
|
repo: Optional[str] = None,
|
|
503
|
-
) ->
|
|
506
|
+
) -> Dict[str, Any]:
|
|
504
507
|
"""
|
|
505
|
-
|
|
508
|
+
Merge a pull request.
|
|
506
509
|
|
|
507
510
|
Args:
|
|
508
|
-
|
|
511
|
+
pr_number: PR number
|
|
512
|
+
commit_title: Custom commit title (for squash/merge)
|
|
513
|
+
commit_message: Custom commit message
|
|
514
|
+
merge_method: 'merge', 'squash', or 'rebase'
|
|
509
515
|
owner: Repository owner
|
|
510
516
|
repo: Repository name
|
|
511
517
|
|
|
512
518
|
Returns:
|
|
513
|
-
|
|
519
|
+
Dict with merge status and SHA
|
|
514
520
|
"""
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
commit = gh_repo.get_commit(sha)
|
|
521
|
+
gh_repo = self._get_repo(owner, repo)
|
|
522
|
+
pr = gh_repo.get_pull(pr_number)
|
|
518
523
|
|
|
524
|
+
# Check if PR is mergeable
|
|
525
|
+
if pr.merged:
|
|
519
526
|
return {
|
|
520
|
-
"
|
|
521
|
-
"message":
|
|
522
|
-
"
|
|
523
|
-
"date": commit.commit.author.date.isoformat(),
|
|
524
|
-
"html_url": commit.html_url,
|
|
525
|
-
"stats": {
|
|
526
|
-
"additions": commit.stats.additions,
|
|
527
|
-
"deletions": commit.stats.deletions,
|
|
528
|
-
"total": commit.stats.total,
|
|
529
|
-
},
|
|
530
|
-
"files": [
|
|
531
|
-
{
|
|
532
|
-
"filename": f.filename,
|
|
533
|
-
"status": f.status,
|
|
534
|
-
"additions": f.additions,
|
|
535
|
-
"deletions": f.deletions,
|
|
536
|
-
"patch": f.patch[:1000] if f.patch else None,
|
|
537
|
-
}
|
|
538
|
-
for f in commit.files[:30] # Limit files
|
|
539
|
-
],
|
|
527
|
+
"merged": True,
|
|
528
|
+
"message": "PR was already merged",
|
|
529
|
+
"sha": pr.merge_commit_sha,
|
|
540
530
|
}
|
|
541
|
-
except GithubException as e:
|
|
542
|
-
if e.status == 404:
|
|
543
|
-
return None
|
|
544
|
-
raise
|
|
545
531
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
532
|
+
if not pr.mergeable:
|
|
533
|
+
return {
|
|
534
|
+
"merged": False,
|
|
535
|
+
"message": "PR is not mergeable (conflicts or checks failing)",
|
|
536
|
+
"sha": None,
|
|
537
|
+
}
|
|
549
538
|
|
|
550
|
-
|
|
539
|
+
# Merge the PR
|
|
540
|
+
result = pr.merge(
|
|
541
|
+
commit_title=commit_title,
|
|
542
|
+
commit_message=commit_message,
|
|
543
|
+
merge_method=merge_method,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
"merged": result.merged,
|
|
548
|
+
"message": result.message,
|
|
549
|
+
"sha": result.sha,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
def close_pr(
|
|
551
553
|
self,
|
|
554
|
+
pr_number: int,
|
|
552
555
|
owner: Optional[str] = None,
|
|
553
556
|
repo: Optional[str] = None,
|
|
554
|
-
|
|
555
|
-
|
|
557
|
+
) -> PullRequest:
|
|
558
|
+
"""Close a pull request without merging."""
|
|
559
|
+
gh_repo = self._get_repo(owner, repo)
|
|
560
|
+
pr = gh_repo.get_pull(pr_number)
|
|
561
|
+
pr.edit(state="closed")
|
|
562
|
+
return self._convert_pr(pr)
|
|
563
|
+
|
|
564
|
+
def reopen_pr(
|
|
565
|
+
self,
|
|
566
|
+
pr_number: int,
|
|
567
|
+
owner: Optional[str] = None,
|
|
568
|
+
repo: Optional[str] = None,
|
|
569
|
+
) -> PullRequest:
|
|
570
|
+
"""Reopen a closed pull request."""
|
|
571
|
+
gh_repo = self._get_repo(owner, repo)
|
|
572
|
+
pr = gh_repo.get_pull(pr_number)
|
|
573
|
+
pr.edit(state="open")
|
|
574
|
+
return self._convert_pr(pr)
|
|
575
|
+
|
|
576
|
+
def add_pr_comment(
|
|
577
|
+
self,
|
|
578
|
+
pr_number: int,
|
|
579
|
+
body: str,
|
|
580
|
+
owner: Optional[str] = None,
|
|
581
|
+
repo: Optional[str] = None,
|
|
582
|
+
) -> Dict[str, Any]:
|
|
556
583
|
"""
|
|
557
|
-
|
|
584
|
+
Add a comment to a pull request.
|
|
558
585
|
|
|
559
586
|
Args:
|
|
587
|
+
pr_number: PR number
|
|
588
|
+
body: Comment text
|
|
560
589
|
owner: Repository owner
|
|
561
590
|
repo: Repository name
|
|
562
|
-
limit: Maximum branches to return
|
|
563
591
|
|
|
564
592
|
Returns:
|
|
565
|
-
|
|
593
|
+
Comment details
|
|
566
594
|
"""
|
|
567
595
|
gh_repo = self._get_repo(owner, repo)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
branches.append(
|
|
572
|
-
{
|
|
573
|
-
"name": branch.name,
|
|
574
|
-
"sha": branch.commit.sha,
|
|
575
|
-
"protected": branch.protected,
|
|
576
|
-
}
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
return branches
|
|
580
|
-
|
|
581
|
-
# ========================================================================
|
|
582
|
-
# Issue Operations
|
|
583
|
-
# ========================================================================
|
|
584
|
-
|
|
585
|
-
def _issue_to_dict(self, issue, summary: bool = False) -> Dict[str, Any]:
|
|
586
|
-
"""Convert PyGithub Issue to dict."""
|
|
587
|
-
if summary:
|
|
588
|
-
return {
|
|
589
|
-
"number": issue.number,
|
|
590
|
-
"title": issue.title,
|
|
591
|
-
"state": issue.state,
|
|
592
|
-
"labels": [label.name for label in issue.labels],
|
|
593
|
-
"html_url": issue.html_url,
|
|
594
|
-
}
|
|
596
|
+
# PRs use the issue comment API
|
|
597
|
+
issue = gh_repo.get_issue(pr_number)
|
|
598
|
+
comment = issue.create_comment(body)
|
|
595
599
|
return {
|
|
596
|
-
"
|
|
597
|
-
"
|
|
598
|
-
"
|
|
599
|
-
"
|
|
600
|
-
"
|
|
601
|
-
"labels": [label.name for label in issue.labels],
|
|
602
|
-
"assignees": [a.login for a in issue.assignees],
|
|
603
|
-
"created_at": issue.created_at.isoformat(),
|
|
600
|
+
"id": comment.id,
|
|
601
|
+
"body": comment.body,
|
|
602
|
+
"html_url": comment.html_url,
|
|
603
|
+
"created_at": comment.created_at.isoformat(),
|
|
604
|
+
"pr_number": pr_number,
|
|
604
605
|
}
|
|
605
606
|
|
|
606
|
-
def
|
|
607
|
+
def request_reviewers(
|
|
607
608
|
self,
|
|
609
|
+
pr_number: int,
|
|
610
|
+
reviewers: Optional[List[str]] = None,
|
|
611
|
+
team_reviewers: Optional[List[str]] = None,
|
|
608
612
|
owner: Optional[str] = None,
|
|
609
613
|
repo: Optional[str] = None,
|
|
610
|
-
|
|
611
|
-
labels: Optional[List[str]] = None,
|
|
612
|
-
assignee: Optional[str] = None,
|
|
613
|
-
creator: Optional[str] = None,
|
|
614
|
-
milestone: Optional[str] = None,
|
|
615
|
-
sort: str = "updated",
|
|
616
|
-
limit: int = 30,
|
|
617
|
-
) -> List[Dict[str, Any]]:
|
|
614
|
+
) -> Dict[str, Any]:
|
|
618
615
|
"""
|
|
619
|
-
|
|
616
|
+
Request reviewers for a pull request.
|
|
620
617
|
|
|
621
618
|
Args:
|
|
619
|
+
pr_number: PR number
|
|
620
|
+
reviewers: List of GitHub usernames
|
|
621
|
+
team_reviewers: List of team slugs (org teams)
|
|
622
622
|
owner: Repository owner
|
|
623
623
|
repo: Repository name
|
|
624
|
-
state: Issue state: 'open', 'closed', or 'all'
|
|
625
|
-
labels: Filter by labels
|
|
626
|
-
assignee: Filter by assignee username
|
|
627
|
-
creator: Filter by issue creator username
|
|
628
|
-
milestone: Filter by milestone (number, title, or '*' for any, 'none' for no milestone)
|
|
629
|
-
sort: Sort by 'created', 'updated', or 'comments'
|
|
630
|
-
limit: Maximum issues to return
|
|
631
624
|
|
|
632
625
|
Returns:
|
|
633
|
-
|
|
626
|
+
Dict with requested reviewers
|
|
634
627
|
"""
|
|
635
628
|
gh_repo = self._get_repo(owner, repo)
|
|
629
|
+
pr = gh_repo.get_pull(pr_number)
|
|
636
630
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if creator:
|
|
643
|
-
kwargs["creator"] = creator
|
|
644
|
-
if milestone:
|
|
645
|
-
# Handle milestone - can be number, '*', 'none', or title
|
|
646
|
-
if milestone == "*" or milestone == "none":
|
|
647
|
-
kwargs["milestone"] = milestone
|
|
648
|
-
elif milestone.isdigit():
|
|
649
|
-
kwargs["milestone"] = gh_repo.get_milestone(int(milestone))
|
|
650
|
-
else:
|
|
651
|
-
# Search by title
|
|
652
|
-
for ms in gh_repo.get_milestones(state="all"):
|
|
653
|
-
if ms.title.lower() == milestone.lower():
|
|
654
|
-
kwargs["milestone"] = ms
|
|
655
|
-
break
|
|
656
|
-
|
|
657
|
-
issues = []
|
|
658
|
-
count = 0
|
|
659
|
-
for issue in gh_repo.get_issues(**kwargs):
|
|
660
|
-
# Skip pull requests (GitHub API returns PRs in issues endpoint)
|
|
661
|
-
if issue.pull_request is not None:
|
|
662
|
-
continue
|
|
663
|
-
issues.append(self._issue_to_dict(issue, summary=True))
|
|
664
|
-
count += 1
|
|
665
|
-
if count >= limit:
|
|
666
|
-
break
|
|
631
|
+
# Create review request
|
|
632
|
+
pr.create_review_request(
|
|
633
|
+
reviewers=reviewers or [],
|
|
634
|
+
team_reviewers=team_reviewers or [],
|
|
635
|
+
)
|
|
667
636
|
|
|
668
|
-
|
|
637
|
+
# Re-fetch to get updated reviewers
|
|
638
|
+
pr = gh_repo.get_pull(pr_number)
|
|
639
|
+
return {
|
|
640
|
+
"pr_number": pr_number,
|
|
641
|
+
"requested_reviewers": [r.login for r in pr.requested_reviewers],
|
|
642
|
+
"requested_teams": [t.slug for t in pr.requested_teams],
|
|
643
|
+
}
|
|
669
644
|
|
|
670
|
-
def
|
|
645
|
+
def submit_pr_review(
|
|
671
646
|
self,
|
|
672
|
-
|
|
647
|
+
pr_number: int,
|
|
648
|
+
event: str,
|
|
673
649
|
body: Optional[str] = None,
|
|
674
|
-
labels: Optional[List[str]] = None,
|
|
675
|
-
assignees: Optional[List[str]] = None,
|
|
676
650
|
owner: Optional[str] = None,
|
|
677
651
|
repo: Optional[str] = None,
|
|
678
652
|
) -> Dict[str, Any]:
|
|
679
|
-
"""
|
|
680
|
-
|
|
681
|
-
issue = gh_repo.create_issue(
|
|
682
|
-
title=title,
|
|
683
|
-
body=body or "",
|
|
684
|
-
labels=labels or [],
|
|
685
|
-
assignees=assignees or [],
|
|
686
|
-
)
|
|
687
|
-
return self._issue_to_dict(issue)
|
|
688
|
-
|
|
689
|
-
def update_issue(
|
|
690
|
-
self,
|
|
691
|
-
issue_number: int,
|
|
692
|
-
title: Optional[str] = None,
|
|
693
|
-
body: Optional[str] = None,
|
|
694
|
-
labels: Optional[List[str]] = None,
|
|
695
|
-
assignees: Optional[List[str]] = None,
|
|
696
|
-
owner: Optional[str] = None,
|
|
697
|
-
repo: Optional[str] = None,
|
|
698
|
-
) -> Dict[str, Any]:
|
|
699
|
-
"""Update a GitHub issue."""
|
|
700
|
-
gh_repo = self._get_repo(owner, repo)
|
|
701
|
-
issue = gh_repo.get_issue(issue_number)
|
|
702
|
-
|
|
703
|
-
kwargs = {}
|
|
704
|
-
if title is not None:
|
|
705
|
-
kwargs["title"] = title
|
|
706
|
-
if body is not None:
|
|
707
|
-
kwargs["body"] = body
|
|
708
|
-
if labels is not None:
|
|
709
|
-
kwargs["labels"] = labels
|
|
710
|
-
if assignees is not None:
|
|
711
|
-
kwargs["assignees"] = assignees
|
|
712
|
-
|
|
713
|
-
if kwargs:
|
|
714
|
-
issue.edit(**kwargs)
|
|
653
|
+
"""
|
|
654
|
+
Submit a review on a pull request.
|
|
715
655
|
|
|
716
|
-
|
|
656
|
+
Args:
|
|
657
|
+
pr_number: PR number
|
|
658
|
+
event: Review event - 'APPROVE', 'REQUEST_CHANGES', or 'COMMENT'
|
|
659
|
+
body: Review comment (required for REQUEST_CHANGES)
|
|
660
|
+
owner: Repository owner
|
|
661
|
+
repo: Repository name
|
|
717
662
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
owner: Optional[str] = None,
|
|
722
|
-
repo: Optional[str] = None,
|
|
723
|
-
) -> Dict[str, Any]:
|
|
724
|
-
"""Close a GitHub issue."""
|
|
663
|
+
Returns:
|
|
664
|
+
Review details
|
|
665
|
+
"""
|
|
725
666
|
gh_repo = self._get_repo(owner, repo)
|
|
726
|
-
|
|
727
|
-
issue.edit(state="closed")
|
|
728
|
-
return self._issue_to_dict(issue)
|
|
667
|
+
pr = gh_repo.get_pull(pr_number)
|
|
729
668
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
repo: Optional[str] = None,
|
|
735
|
-
) -> Dict[str, Any]:
|
|
736
|
-
"""Reopen a GitHub issue."""
|
|
737
|
-
gh_repo = self._get_repo(owner, repo)
|
|
738
|
-
issue = gh_repo.get_issue(issue_number)
|
|
739
|
-
issue.edit(state="open")
|
|
740
|
-
return self._issue_to_dict(issue)
|
|
669
|
+
review = pr.create_review(
|
|
670
|
+
body=body or "",
|
|
671
|
+
event=event,
|
|
672
|
+
)
|
|
741
673
|
|
|
742
|
-
def comment_on_issue(
|
|
743
|
-
self,
|
|
744
|
-
issue_number: int,
|
|
745
|
-
body: str,
|
|
746
|
-
owner: Optional[str] = None,
|
|
747
|
-
repo: Optional[str] = None,
|
|
748
|
-
) -> Dict[str, Any]:
|
|
749
|
-
"""Add a comment to a GitHub issue."""
|
|
750
|
-
gh_repo = self._get_repo(owner, repo)
|
|
751
|
-
issue = gh_repo.get_issue(issue_number)
|
|
752
|
-
comment = issue.create_comment(body)
|
|
753
674
|
return {
|
|
754
|
-
"id":
|
|
755
|
-
"
|
|
756
|
-
"
|
|
757
|
-
"
|
|
758
|
-
"
|
|
675
|
+
"id": review.id,
|
|
676
|
+
"state": review.state,
|
|
677
|
+
"body": review.body,
|
|
678
|
+
"html_url": review.html_url,
|
|
679
|
+
"pr_number": pr_number,
|
|
759
680
|
}
|
|
760
681
|
|
|
761
|
-
def
|
|
682
|
+
def convert_pr_to_draft(
|
|
762
683
|
self,
|
|
763
|
-
|
|
684
|
+
pr_number: int,
|
|
764
685
|
owner: Optional[str] = None,
|
|
765
686
|
repo: Optional[str] = None,
|
|
766
|
-
include_sub_issues: bool = True,
|
|
767
687
|
) -> Dict[str, Any]:
|
|
768
688
|
"""
|
|
769
|
-
|
|
689
|
+
Convert a PR to draft status using GraphQL API.
|
|
770
690
|
|
|
771
691
|
Args:
|
|
772
|
-
|
|
692
|
+
pr_number: PR number
|
|
773
693
|
owner: Repository owner
|
|
774
694
|
repo: Repository name
|
|
775
|
-
include_sub_issues: Whether to fetch sub-issues list
|
|
776
695
|
|
|
777
696
|
Returns:
|
|
778
|
-
|
|
697
|
+
Dict with success status
|
|
779
698
|
"""
|
|
780
|
-
|
|
781
|
-
|
|
699
|
+
owner = owner or self.default_owner
|
|
700
|
+
repo = repo or self.default_repo
|
|
782
701
|
|
|
783
|
-
|
|
784
|
-
"
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
"comments_count": issue.comments,
|
|
796
|
-
"author": issue.user.login if issue.user else "unknown",
|
|
702
|
+
if not owner or not repo:
|
|
703
|
+
raise ValueError("Repository owner and name must be specified")
|
|
704
|
+
|
|
705
|
+
# Get PR node ID
|
|
706
|
+
query = """
|
|
707
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
708
|
+
repository(owner: $owner, name: $repo) {
|
|
709
|
+
pullRequest(number: $number) {
|
|
710
|
+
id
|
|
711
|
+
isDraft
|
|
712
|
+
}
|
|
713
|
+
}
|
|
797
714
|
}
|
|
715
|
+
"""
|
|
716
|
+
data = self._graphql_request(
|
|
717
|
+
query, {"owner": owner, "repo": repo, "number": pr_number}
|
|
718
|
+
)
|
|
719
|
+
repository = data.get("repository")
|
|
720
|
+
if not repository:
|
|
721
|
+
raise GithubException(
|
|
722
|
+
404, {"message": f"Repository {owner}/{repo} not found"}
|
|
723
|
+
)
|
|
724
|
+
pr_data = repository.get("pullRequest")
|
|
725
|
+
if not pr_data:
|
|
726
|
+
raise GithubException(404, {"message": f"PR #{pr_number} not found"})
|
|
798
727
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
result["sub_issues_count"] = len(sub_issues)
|
|
728
|
+
if pr_data.get("isDraft"):
|
|
729
|
+
return {
|
|
730
|
+
"pr_number": pr_number,
|
|
731
|
+
"is_draft": True,
|
|
732
|
+
"message": "PR is already a draft",
|
|
733
|
+
}
|
|
806
734
|
|
|
807
|
-
|
|
735
|
+
pr_node_id = pr_data["id"]
|
|
808
736
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
737
|
+
# Convert to draft
|
|
738
|
+
mutation = """
|
|
739
|
+
mutation($pullRequestId: ID!) {
|
|
740
|
+
convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) {
|
|
741
|
+
pullRequest {
|
|
742
|
+
id
|
|
743
|
+
isDraft
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
"""
|
|
748
|
+
result = self._graphql_request(mutation, {"pullRequestId": pr_node_id})
|
|
749
|
+
converted = result.get("convertPullRequestToDraft", {}).get("pullRequest", {})
|
|
812
750
|
|
|
813
|
-
|
|
751
|
+
return {
|
|
752
|
+
"pr_number": pr_number,
|
|
753
|
+
"is_draft": converted.get("isDraft", True),
|
|
754
|
+
"message": "PR converted to draft",
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
def mark_pr_ready_for_review(
|
|
814
758
|
self,
|
|
815
|
-
|
|
759
|
+
pr_number: int,
|
|
816
760
|
owner: Optional[str] = None,
|
|
817
761
|
repo: Optional[str] = None,
|
|
818
|
-
) ->
|
|
762
|
+
) -> Dict[str, Any]:
|
|
819
763
|
"""
|
|
820
|
-
|
|
764
|
+
Mark a draft PR as ready for review using GraphQL API.
|
|
821
765
|
|
|
822
766
|
Args:
|
|
823
|
-
|
|
767
|
+
pr_number: PR number
|
|
824
768
|
owner: Repository owner
|
|
825
769
|
repo: Repository name
|
|
826
770
|
|
|
827
771
|
Returns:
|
|
828
|
-
|
|
772
|
+
Dict with success status
|
|
829
773
|
"""
|
|
830
774
|
owner = owner or self.default_owner
|
|
831
775
|
repo = repo or self.default_repo
|
|
@@ -833,325 +777,1827 @@ class GitHubClient:
|
|
|
833
777
|
if not owner or not repo:
|
|
834
778
|
raise ValueError("Repository owner and name must be specified")
|
|
835
779
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
780
|
+
# Get PR node ID
|
|
781
|
+
query = """
|
|
782
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
783
|
+
repository(owner: $owner, name: $repo) {
|
|
784
|
+
pullRequest(number: $number) {
|
|
785
|
+
id
|
|
786
|
+
isDraft
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
"""
|
|
791
|
+
data = self._graphql_request(
|
|
792
|
+
query, {"owner": owner, "repo": repo, "number": pr_number}
|
|
793
|
+
)
|
|
794
|
+
repository = data.get("repository")
|
|
795
|
+
if not repository:
|
|
796
|
+
raise GithubException(
|
|
797
|
+
404, {"message": f"Repository {owner}/{repo} not found"}
|
|
798
|
+
)
|
|
799
|
+
pr_data = repository.get("pullRequest")
|
|
800
|
+
if not pr_data:
|
|
801
|
+
raise GithubException(404, {"message": f"PR #{pr_number} not found"})
|
|
849
802
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
803
|
+
if not pr_data.get("isDraft"):
|
|
804
|
+
return {
|
|
805
|
+
"pr_number": pr_number,
|
|
806
|
+
"is_draft": False,
|
|
807
|
+
"message": "PR is already ready for review",
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
pr_node_id = pr_data["id"]
|
|
811
|
+
|
|
812
|
+
# Mark ready for review
|
|
813
|
+
mutation = """
|
|
814
|
+
mutation($pullRequestId: ID!) {
|
|
815
|
+
markPullRequestReadyForReview(input: {pullRequestId: $pullRequestId}) {
|
|
816
|
+
pullRequest {
|
|
817
|
+
id
|
|
818
|
+
isDraft
|
|
857
819
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
raise GithubException(e.response.status_code, e.response.json())
|
|
866
|
-
except Exception as e:
|
|
867
|
-
logger.error(f"Failed to list sub-issues: {e}")
|
|
868
|
-
return []
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
"""
|
|
823
|
+
result = self._graphql_request(mutation, {"pullRequestId": pr_node_id})
|
|
824
|
+
converted = result.get("markPullRequestReadyForReview", {}).get(
|
|
825
|
+
"pullRequest", {}
|
|
826
|
+
)
|
|
869
827
|
|
|
870
|
-
|
|
828
|
+
return {
|
|
829
|
+
"pr_number": pr_number,
|
|
830
|
+
"is_draft": converted.get("isDraft", False),
|
|
831
|
+
"message": "PR marked ready for review",
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
def add_pr_labels(
|
|
871
835
|
self,
|
|
872
|
-
|
|
873
|
-
|
|
836
|
+
pr_number: int,
|
|
837
|
+
labels: List[str],
|
|
874
838
|
owner: Optional[str] = None,
|
|
875
839
|
repo: Optional[str] = None,
|
|
876
840
|
) -> Dict[str, Any]:
|
|
877
841
|
"""
|
|
878
|
-
Add
|
|
842
|
+
Add labels to a pull request.
|
|
879
843
|
|
|
880
844
|
Args:
|
|
881
|
-
|
|
882
|
-
|
|
845
|
+
pr_number: PR number
|
|
846
|
+
labels: List of label names
|
|
883
847
|
owner: Repository owner
|
|
884
848
|
repo: Repository name
|
|
885
849
|
|
|
886
850
|
Returns:
|
|
887
|
-
|
|
851
|
+
Dict with updated labels
|
|
888
852
|
"""
|
|
889
|
-
owner = owner or self.default_owner
|
|
890
|
-
repo = repo or self.default_repo
|
|
891
|
-
|
|
892
|
-
if not owner or not repo:
|
|
893
|
-
raise ValueError("Repository owner and name must be specified")
|
|
894
|
-
|
|
895
|
-
# First, get the child issue's internal ID (required by API)
|
|
896
853
|
gh_repo = self._get_repo(owner, repo)
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
try:
|
|
901
|
-
with httpx.Client() as client:
|
|
902
|
-
response = client.post(
|
|
903
|
-
f"https://api.github.com/repos/{owner}/{repo}/issues/{parent_issue_number}/sub_issues",
|
|
904
|
-
headers={
|
|
905
|
-
"Authorization": f"Bearer {self.token}",
|
|
906
|
-
"Accept": "application/vnd.github+json",
|
|
907
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
908
|
-
},
|
|
909
|
-
json={"sub_issue_id": child_id},
|
|
910
|
-
timeout=30.0,
|
|
911
|
-
)
|
|
912
|
-
response.raise_for_status()
|
|
854
|
+
# PRs use the issue API for labels
|
|
855
|
+
issue = gh_repo.get_issue(pr_number)
|
|
856
|
+
issue.add_to_labels(*labels)
|
|
913
857
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
"child_id": child_id,
|
|
919
|
-
}
|
|
920
|
-
except httpx.HTTPStatusError as e:
|
|
921
|
-
logger.error(f"Failed to add sub-issue: HTTP {e.response.status_code}")
|
|
922
|
-
error_data = e.response.json() if e.response.content else {}
|
|
923
|
-
raise GithubException(
|
|
924
|
-
e.response.status_code,
|
|
925
|
-
error_data,
|
|
926
|
-
message=f"Failed to add #{child_issue_number} as sub-issue of #{parent_issue_number}",
|
|
927
|
-
)
|
|
858
|
+
return {
|
|
859
|
+
"pr_number": pr_number,
|
|
860
|
+
"labels": [label.name for label in issue.labels],
|
|
861
|
+
}
|
|
928
862
|
|
|
929
|
-
def
|
|
863
|
+
def remove_pr_labels(
|
|
930
864
|
self,
|
|
931
|
-
|
|
932
|
-
|
|
865
|
+
pr_number: int,
|
|
866
|
+
labels: List[str],
|
|
933
867
|
owner: Optional[str] = None,
|
|
934
868
|
repo: Optional[str] = None,
|
|
935
869
|
) -> Dict[str, Any]:
|
|
936
870
|
"""
|
|
937
|
-
Remove
|
|
871
|
+
Remove labels from a pull request.
|
|
938
872
|
|
|
939
873
|
Args:
|
|
940
|
-
|
|
941
|
-
|
|
874
|
+
pr_number: PR number
|
|
875
|
+
labels: List of label names to remove
|
|
942
876
|
owner: Repository owner
|
|
943
877
|
repo: Repository name
|
|
944
878
|
|
|
945
879
|
Returns:
|
|
946
|
-
|
|
880
|
+
Dict with updated labels
|
|
947
881
|
"""
|
|
948
|
-
|
|
949
|
-
|
|
882
|
+
gh_repo = self._get_repo(owner, repo)
|
|
883
|
+
issue = gh_repo.get_issue(pr_number)
|
|
950
884
|
|
|
951
|
-
|
|
885
|
+
for label in labels:
|
|
886
|
+
try:
|
|
887
|
+
issue.remove_from_labels(label)
|
|
888
|
+
except GithubException as e:
|
|
889
|
+
if e.status != 404: # Ignore if label not present
|
|
890
|
+
raise
|
|
891
|
+
|
|
892
|
+
# Re-fetch to get updated labels
|
|
893
|
+
issue = gh_repo.get_issue(pr_number)
|
|
894
|
+
return {
|
|
895
|
+
"pr_number": pr_number,
|
|
896
|
+
"labels": [label.name for label in issue.labels],
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
def add_pr_assignees(
|
|
900
|
+
self,
|
|
901
|
+
pr_number: int,
|
|
902
|
+
assignees: List[str],
|
|
903
|
+
owner: Optional[str] = None,
|
|
904
|
+
repo: Optional[str] = None,
|
|
905
|
+
) -> Dict[str, Any]:
|
|
906
|
+
"""
|
|
907
|
+
Add assignees to a pull request.
|
|
908
|
+
|
|
909
|
+
Args:
|
|
910
|
+
pr_number: PR number
|
|
911
|
+
assignees: List of GitHub usernames
|
|
912
|
+
owner: Repository owner
|
|
913
|
+
repo: Repository name
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
Dict with updated assignees
|
|
917
|
+
"""
|
|
918
|
+
gh_repo = self._get_repo(owner, repo)
|
|
919
|
+
issue = gh_repo.get_issue(pr_number)
|
|
920
|
+
issue.add_to_assignees(*assignees)
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
"pr_number": pr_number,
|
|
924
|
+
"assignees": [a.login for a in issue.assignees],
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
def remove_pr_assignees(
|
|
928
|
+
self,
|
|
929
|
+
pr_number: int,
|
|
930
|
+
assignees: List[str],
|
|
931
|
+
owner: Optional[str] = None,
|
|
932
|
+
repo: Optional[str] = None,
|
|
933
|
+
) -> Dict[str, Any]:
|
|
934
|
+
"""
|
|
935
|
+
Remove assignees from a pull request.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
pr_number: PR number
|
|
939
|
+
assignees: List of GitHub usernames to remove
|
|
940
|
+
owner: Repository owner
|
|
941
|
+
repo: Repository name
|
|
942
|
+
|
|
943
|
+
Returns:
|
|
944
|
+
Dict with updated assignees
|
|
945
|
+
"""
|
|
946
|
+
gh_repo = self._get_repo(owner, repo)
|
|
947
|
+
issue = gh_repo.get_issue(pr_number)
|
|
948
|
+
|
|
949
|
+
for assignee in assignees:
|
|
950
|
+
try:
|
|
951
|
+
issue.remove_from_assignees(assignee)
|
|
952
|
+
except GithubException as e:
|
|
953
|
+
if e.status != 404: # Ignore if not assigned
|
|
954
|
+
raise
|
|
955
|
+
|
|
956
|
+
# Re-fetch to get updated assignees
|
|
957
|
+
issue = gh_repo.get_issue(pr_number)
|
|
958
|
+
return {
|
|
959
|
+
"pr_number": pr_number,
|
|
960
|
+
"assignees": [a.login for a in issue.assignees],
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
# ========================================================================
|
|
964
|
+
# Commit Operations
|
|
965
|
+
# ========================================================================
|
|
966
|
+
|
|
967
|
+
def list_commits(
|
|
968
|
+
self,
|
|
969
|
+
owner: Optional[str] = None,
|
|
970
|
+
repo: Optional[str] = None,
|
|
971
|
+
sha: Optional[str] = None,
|
|
972
|
+
author: Optional[str] = None,
|
|
973
|
+
since: Optional[str] = None,
|
|
974
|
+
limit: int = 20,
|
|
975
|
+
detail_level: str = "summary",
|
|
976
|
+
) -> List[Commit] | List[CommitSummary]:
|
|
977
|
+
"""
|
|
978
|
+
List commits.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
owner: Repository owner
|
|
982
|
+
repo: Repository name
|
|
983
|
+
sha: Branch name or commit SHA to start from
|
|
984
|
+
author: Filter by author username
|
|
985
|
+
since: ISO datetime - only commits after this date
|
|
986
|
+
limit: Maximum commits to return
|
|
987
|
+
detail_level: 'summary' for minimal fields, 'full' for all fields
|
|
988
|
+
|
|
989
|
+
Returns:
|
|
990
|
+
List of commits (summary or full based on detail_level)
|
|
991
|
+
"""
|
|
992
|
+
gh_repo = self._get_repo(owner, repo)
|
|
993
|
+
|
|
994
|
+
kwargs = {}
|
|
995
|
+
if sha:
|
|
996
|
+
kwargs["sha"] = sha
|
|
997
|
+
if since:
|
|
998
|
+
kwargs["since"] = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
999
|
+
|
|
1000
|
+
commits = []
|
|
1001
|
+
for commit in gh_repo.get_commits(**kwargs):
|
|
1002
|
+
if len(commits) >= limit:
|
|
1003
|
+
break
|
|
1004
|
+
|
|
1005
|
+
# Get author login
|
|
1006
|
+
commit_author = "unknown"
|
|
1007
|
+
if commit.author:
|
|
1008
|
+
commit_author = commit.author.login
|
|
1009
|
+
elif commit.commit.author:
|
|
1010
|
+
commit_author = commit.commit.author.name
|
|
1011
|
+
|
|
1012
|
+
# Apply author filter
|
|
1013
|
+
if author and author.lower() != commit_author.lower():
|
|
1014
|
+
continue
|
|
1015
|
+
|
|
1016
|
+
if detail_level == "full":
|
|
1017
|
+
commits.append(
|
|
1018
|
+
Commit(
|
|
1019
|
+
sha=commit.sha,
|
|
1020
|
+
message=commit.commit.message,
|
|
1021
|
+
author=commit_author,
|
|
1022
|
+
date=commit.commit.author.date,
|
|
1023
|
+
html_url=commit.html_url,
|
|
1024
|
+
)
|
|
1025
|
+
)
|
|
1026
|
+
else:
|
|
1027
|
+
# Summary: just first line of message
|
|
1028
|
+
message_title = commit.commit.message.split("\n")[0][:100]
|
|
1029
|
+
commits.append(
|
|
1030
|
+
CommitSummary(
|
|
1031
|
+
sha=commit.sha[:7], # Short SHA for summary
|
|
1032
|
+
message_title=message_title,
|
|
1033
|
+
author=commit_author,
|
|
1034
|
+
date=commit.commit.author.date,
|
|
1035
|
+
html_url=commit.html_url,
|
|
1036
|
+
)
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
return commits
|
|
1040
|
+
|
|
1041
|
+
def get_commit(
|
|
1042
|
+
self,
|
|
1043
|
+
sha: str,
|
|
1044
|
+
owner: Optional[str] = None,
|
|
1045
|
+
repo: Optional[str] = None,
|
|
1046
|
+
) -> Optional[Dict[str, Any]]:
|
|
1047
|
+
"""
|
|
1048
|
+
Get detailed commit information including file changes.
|
|
1049
|
+
|
|
1050
|
+
Args:
|
|
1051
|
+
sha: Commit SHA
|
|
1052
|
+
owner: Repository owner
|
|
1053
|
+
repo: Repository name
|
|
1054
|
+
|
|
1055
|
+
Returns:
|
|
1056
|
+
Commit details with files or None if not found
|
|
1057
|
+
"""
|
|
1058
|
+
try:
|
|
1059
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1060
|
+
commit = gh_repo.get_commit(sha)
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
"sha": commit.sha,
|
|
1064
|
+
"message": commit.commit.message,
|
|
1065
|
+
"author": commit.author.login if commit.author else "unknown",
|
|
1066
|
+
"date": commit.commit.author.date.isoformat(),
|
|
1067
|
+
"html_url": commit.html_url,
|
|
1068
|
+
"stats": {
|
|
1069
|
+
"additions": commit.stats.additions,
|
|
1070
|
+
"deletions": commit.stats.deletions,
|
|
1071
|
+
"total": commit.stats.total,
|
|
1072
|
+
},
|
|
1073
|
+
"files": [
|
|
1074
|
+
{
|
|
1075
|
+
"filename": f.filename,
|
|
1076
|
+
"status": f.status,
|
|
1077
|
+
"additions": f.additions,
|
|
1078
|
+
"deletions": f.deletions,
|
|
1079
|
+
"patch": f.patch[:1000] if f.patch else None,
|
|
1080
|
+
}
|
|
1081
|
+
for f in commit.files[:30] # Limit files
|
|
1082
|
+
],
|
|
1083
|
+
}
|
|
1084
|
+
except GithubException as e:
|
|
1085
|
+
if e.status == 404:
|
|
1086
|
+
return None
|
|
1087
|
+
raise
|
|
1088
|
+
|
|
1089
|
+
# ========================================================================
|
|
1090
|
+
# Branch Operations
|
|
1091
|
+
# ========================================================================
|
|
1092
|
+
|
|
1093
|
+
def list_branches(
|
|
1094
|
+
self,
|
|
1095
|
+
owner: Optional[str] = None,
|
|
1096
|
+
repo: Optional[str] = None,
|
|
1097
|
+
limit: int = 30,
|
|
1098
|
+
) -> List[Dict[str, Any]]:
|
|
1099
|
+
"""
|
|
1100
|
+
List repository branches.
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
owner: Repository owner
|
|
1104
|
+
repo: Repository name
|
|
1105
|
+
limit: Maximum branches to return
|
|
1106
|
+
|
|
1107
|
+
Returns:
|
|
1108
|
+
List of branch info dicts
|
|
1109
|
+
"""
|
|
1110
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1111
|
+
branches = []
|
|
1112
|
+
|
|
1113
|
+
for branch in gh_repo.get_branches()[:limit]:
|
|
1114
|
+
branches.append(
|
|
1115
|
+
{
|
|
1116
|
+
"name": branch.name,
|
|
1117
|
+
"sha": branch.commit.sha,
|
|
1118
|
+
"protected": branch.protected,
|
|
1119
|
+
}
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
return branches
|
|
1123
|
+
|
|
1124
|
+
# ========================================================================
|
|
1125
|
+
# Issue Operations
|
|
1126
|
+
# ========================================================================
|
|
1127
|
+
|
|
1128
|
+
def _issue_to_dict(self, issue, summary: bool = False) -> Dict[str, Any]:
|
|
1129
|
+
"""Convert PyGithub Issue to dict."""
|
|
1130
|
+
if summary:
|
|
1131
|
+
return {
|
|
1132
|
+
"number": issue.number,
|
|
1133
|
+
"title": issue.title,
|
|
1134
|
+
"state": issue.state,
|
|
1135
|
+
"labels": [label.name for label in issue.labels],
|
|
1136
|
+
"html_url": issue.html_url,
|
|
1137
|
+
}
|
|
1138
|
+
return {
|
|
1139
|
+
"number": issue.number,
|
|
1140
|
+
"title": issue.title,
|
|
1141
|
+
"body": issue.body,
|
|
1142
|
+
"state": issue.state,
|
|
1143
|
+
"html_url": issue.html_url,
|
|
1144
|
+
"labels": [label.name for label in issue.labels],
|
|
1145
|
+
"assignees": [a.login for a in issue.assignees],
|
|
1146
|
+
"created_at": issue.created_at.isoformat(),
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
def list_issues(
|
|
1150
|
+
self,
|
|
1151
|
+
owner: Optional[str] = None,
|
|
1152
|
+
repo: Optional[str] = None,
|
|
1153
|
+
state: str = "open",
|
|
1154
|
+
labels: Optional[List[str]] = None,
|
|
1155
|
+
assignee: Optional[str] = None,
|
|
1156
|
+
creator: Optional[str] = None,
|
|
1157
|
+
milestone: Optional[str] = None,
|
|
1158
|
+
sort: str = "updated",
|
|
1159
|
+
limit: int = 30,
|
|
1160
|
+
) -> List[Dict[str, Any]]:
|
|
1161
|
+
"""
|
|
1162
|
+
List issues in a repository.
|
|
1163
|
+
|
|
1164
|
+
Args:
|
|
1165
|
+
owner: Repository owner
|
|
1166
|
+
repo: Repository name
|
|
1167
|
+
state: Issue state: 'open', 'closed', or 'all'
|
|
1168
|
+
labels: Filter by labels
|
|
1169
|
+
assignee: Filter by assignee username
|
|
1170
|
+
creator: Filter by issue creator username
|
|
1171
|
+
milestone: Filter by milestone (number, title, or '*' for any, 'none' for no milestone)
|
|
1172
|
+
sort: Sort by 'created', 'updated', or 'comments'
|
|
1173
|
+
limit: Maximum issues to return
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
List of issue summaries
|
|
1177
|
+
"""
|
|
1178
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1179
|
+
|
|
1180
|
+
kwargs = {"state": state, "sort": sort, "direction": "desc"}
|
|
1181
|
+
if labels:
|
|
1182
|
+
kwargs["labels"] = labels
|
|
1183
|
+
if assignee:
|
|
1184
|
+
kwargs["assignee"] = assignee
|
|
1185
|
+
if creator:
|
|
1186
|
+
kwargs["creator"] = creator
|
|
1187
|
+
if milestone:
|
|
1188
|
+
# Handle milestone - can be number, '*', 'none', or title
|
|
1189
|
+
if milestone == "*" or milestone == "none":
|
|
1190
|
+
kwargs["milestone"] = milestone
|
|
1191
|
+
elif milestone.isdigit():
|
|
1192
|
+
kwargs["milestone"] = gh_repo.get_milestone(int(milestone))
|
|
1193
|
+
else:
|
|
1194
|
+
# Search by title
|
|
1195
|
+
for ms in gh_repo.get_milestones(state="all"):
|
|
1196
|
+
if ms.title.lower() == milestone.lower():
|
|
1197
|
+
kwargs["milestone"] = ms
|
|
1198
|
+
break
|
|
1199
|
+
|
|
1200
|
+
issues = []
|
|
1201
|
+
count = 0
|
|
1202
|
+
for issue in gh_repo.get_issues(**kwargs):
|
|
1203
|
+
# Skip pull requests (GitHub API returns PRs in issues endpoint)
|
|
1204
|
+
if issue.pull_request is not None:
|
|
1205
|
+
continue
|
|
1206
|
+
issues.append(self._issue_to_dict(issue, summary=True))
|
|
1207
|
+
count += 1
|
|
1208
|
+
if count >= limit:
|
|
1209
|
+
break
|
|
1210
|
+
|
|
1211
|
+
return issues
|
|
1212
|
+
|
|
1213
|
+
def create_issue(
|
|
1214
|
+
self,
|
|
1215
|
+
title: str,
|
|
1216
|
+
body: Optional[str] = None,
|
|
1217
|
+
labels: Optional[List[str]] = None,
|
|
1218
|
+
assignees: Optional[List[str]] = None,
|
|
1219
|
+
owner: Optional[str] = None,
|
|
1220
|
+
repo: Optional[str] = None,
|
|
1221
|
+
) -> Dict[str, Any]:
|
|
1222
|
+
"""Create a GitHub issue."""
|
|
1223
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1224
|
+
issue = gh_repo.create_issue(
|
|
1225
|
+
title=title,
|
|
1226
|
+
body=body or "",
|
|
1227
|
+
labels=labels or [],
|
|
1228
|
+
assignees=assignees or [],
|
|
1229
|
+
)
|
|
1230
|
+
return self._issue_to_dict(issue)
|
|
1231
|
+
|
|
1232
|
+
def update_issue(
|
|
1233
|
+
self,
|
|
1234
|
+
issue_number: int,
|
|
1235
|
+
title: Optional[str] = None,
|
|
1236
|
+
body: Optional[str] = None,
|
|
1237
|
+
labels: Optional[List[str]] = None,
|
|
1238
|
+
assignees: Optional[List[str]] = None,
|
|
1239
|
+
owner: Optional[str] = None,
|
|
1240
|
+
repo: Optional[str] = None,
|
|
1241
|
+
) -> Dict[str, Any]:
|
|
1242
|
+
"""Update a GitHub issue."""
|
|
1243
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1244
|
+
issue = gh_repo.get_issue(issue_number)
|
|
1245
|
+
|
|
1246
|
+
kwargs = {}
|
|
1247
|
+
if title is not None:
|
|
1248
|
+
kwargs["title"] = title
|
|
1249
|
+
if body is not None:
|
|
1250
|
+
kwargs["body"] = body
|
|
1251
|
+
if labels is not None:
|
|
1252
|
+
kwargs["labels"] = labels
|
|
1253
|
+
if assignees is not None:
|
|
1254
|
+
kwargs["assignees"] = assignees
|
|
1255
|
+
|
|
1256
|
+
if kwargs:
|
|
1257
|
+
issue.edit(**kwargs)
|
|
1258
|
+
|
|
1259
|
+
return self._issue_to_dict(issue)
|
|
1260
|
+
|
|
1261
|
+
def close_issue(
|
|
1262
|
+
self,
|
|
1263
|
+
issue_number: int,
|
|
1264
|
+
owner: Optional[str] = None,
|
|
1265
|
+
repo: Optional[str] = None,
|
|
1266
|
+
) -> Dict[str, Any]:
|
|
1267
|
+
"""Close a GitHub issue."""
|
|
1268
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1269
|
+
issue = gh_repo.get_issue(issue_number)
|
|
1270
|
+
issue.edit(state="closed")
|
|
1271
|
+
return self._issue_to_dict(issue)
|
|
1272
|
+
|
|
1273
|
+
def reopen_issue(
|
|
1274
|
+
self,
|
|
1275
|
+
issue_number: int,
|
|
1276
|
+
owner: Optional[str] = None,
|
|
1277
|
+
repo: Optional[str] = None,
|
|
1278
|
+
) -> Dict[str, Any]:
|
|
1279
|
+
"""Reopen a GitHub issue."""
|
|
1280
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1281
|
+
issue = gh_repo.get_issue(issue_number)
|
|
1282
|
+
issue.edit(state="open")
|
|
1283
|
+
return self._issue_to_dict(issue)
|
|
1284
|
+
|
|
1285
|
+
def comment_on_issue(
|
|
1286
|
+
self,
|
|
1287
|
+
issue_number: int,
|
|
1288
|
+
body: str,
|
|
1289
|
+
owner: Optional[str] = None,
|
|
1290
|
+
repo: Optional[str] = None,
|
|
1291
|
+
) -> Dict[str, Any]:
|
|
1292
|
+
"""Add a comment to a GitHub issue."""
|
|
1293
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1294
|
+
issue = gh_repo.get_issue(issue_number)
|
|
1295
|
+
comment = issue.create_comment(body)
|
|
1296
|
+
return {
|
|
1297
|
+
"id": comment.id,
|
|
1298
|
+
"body": comment.body,
|
|
1299
|
+
"html_url": comment.html_url,
|
|
1300
|
+
"created_at": comment.created_at.isoformat(),
|
|
1301
|
+
"issue_number": issue_number,
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
def get_issue(
|
|
1305
|
+
self,
|
|
1306
|
+
issue_number: int,
|
|
1307
|
+
owner: Optional[str] = None,
|
|
1308
|
+
repo: Optional[str] = None,
|
|
1309
|
+
include_sub_issues: bool = True,
|
|
1310
|
+
) -> Dict[str, Any]:
|
|
1311
|
+
"""
|
|
1312
|
+
Get detailed information about a GitHub issue.
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
issue_number: Issue number
|
|
1316
|
+
owner: Repository owner
|
|
1317
|
+
repo: Repository name
|
|
1318
|
+
include_sub_issues: Whether to fetch sub-issues list
|
|
1319
|
+
|
|
1320
|
+
Returns:
|
|
1321
|
+
Issue details including sub-issues if requested
|
|
1322
|
+
"""
|
|
1323
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1324
|
+
issue = gh_repo.get_issue(issue_number)
|
|
1325
|
+
|
|
1326
|
+
result = {
|
|
1327
|
+
"number": issue.number,
|
|
1328
|
+
"id": issue.id, # Internal ID needed for sub-issues API
|
|
1329
|
+
"title": issue.title,
|
|
1330
|
+
"body": issue.body,
|
|
1331
|
+
"state": issue.state,
|
|
1332
|
+
"html_url": issue.html_url,
|
|
1333
|
+
"labels": [label.name for label in issue.labels],
|
|
1334
|
+
"assignees": [a.login for a in issue.assignees],
|
|
1335
|
+
"created_at": issue.created_at.isoformat(),
|
|
1336
|
+
"updated_at": issue.updated_at.isoformat() if issue.updated_at else None,
|
|
1337
|
+
"closed_at": issue.closed_at.isoformat() if issue.closed_at else None,
|
|
1338
|
+
"comments_count": issue.comments,
|
|
1339
|
+
"author": issue.user.login if issue.user else "unknown",
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
# Fetch sub-issues if requested
|
|
1343
|
+
if include_sub_issues:
|
|
1344
|
+
owner = owner or self.default_owner
|
|
1345
|
+
repo_name = repo or self.default_repo
|
|
1346
|
+
sub_issues = self.list_sub_issues(issue_number, owner=owner, repo=repo_name)
|
|
1347
|
+
result["sub_issues"] = sub_issues
|
|
1348
|
+
result["sub_issues_count"] = len(sub_issues)
|
|
1349
|
+
|
|
1350
|
+
return result
|
|
1351
|
+
|
|
1352
|
+
# ========================================================================
|
|
1353
|
+
# Sub-Issue Operations (GitHub's native sub-issues feature)
|
|
1354
|
+
# ========================================================================
|
|
1355
|
+
|
|
1356
|
+
def list_sub_issues(
|
|
1357
|
+
self,
|
|
1358
|
+
parent_issue_number: int,
|
|
1359
|
+
owner: Optional[str] = None,
|
|
1360
|
+
repo: Optional[str] = None,
|
|
1361
|
+
) -> List[Dict[str, Any]]:
|
|
1362
|
+
"""
|
|
1363
|
+
List sub-issues of a parent issue.
|
|
1364
|
+
|
|
1365
|
+
Args:
|
|
1366
|
+
parent_issue_number: Parent issue number
|
|
1367
|
+
owner: Repository owner
|
|
1368
|
+
repo: Repository name
|
|
1369
|
+
|
|
1370
|
+
Returns:
|
|
1371
|
+
List of sub-issue summaries
|
|
1372
|
+
"""
|
|
1373
|
+
owner = owner or self.default_owner
|
|
1374
|
+
repo = repo or self.default_repo
|
|
1375
|
+
|
|
1376
|
+
if not owner or not repo:
|
|
1377
|
+
raise ValueError("Repository owner and name must be specified")
|
|
1378
|
+
|
|
1379
|
+
try:
|
|
1380
|
+
with httpx.Client() as client:
|
|
1381
|
+
response = client.get(
|
|
1382
|
+
f"https://api.github.com/repos/{owner}/{repo}/issues/{parent_issue_number}/sub_issues",
|
|
1383
|
+
headers={
|
|
1384
|
+
"Authorization": f"Bearer {self.token}",
|
|
1385
|
+
"Accept": "application/vnd.github+json",
|
|
1386
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1387
|
+
},
|
|
1388
|
+
timeout=30.0,
|
|
1389
|
+
)
|
|
1390
|
+
response.raise_for_status()
|
|
1391
|
+
data = response.json()
|
|
1392
|
+
|
|
1393
|
+
return [
|
|
1394
|
+
{
|
|
1395
|
+
"number": item["number"],
|
|
1396
|
+
"id": item["id"],
|
|
1397
|
+
"title": item["title"],
|
|
1398
|
+
"state": item["state"],
|
|
1399
|
+
"html_url": item["html_url"],
|
|
1400
|
+
}
|
|
1401
|
+
for item in data
|
|
1402
|
+
]
|
|
1403
|
+
except httpx.HTTPStatusError as e:
|
|
1404
|
+
if e.response.status_code == 404:
|
|
1405
|
+
# No sub-issues or feature not enabled
|
|
1406
|
+
return []
|
|
1407
|
+
logger.error(f"Failed to list sub-issues: HTTP {e.response.status_code}")
|
|
1408
|
+
raise GithubException(e.response.status_code, e.response.json())
|
|
1409
|
+
except Exception as e:
|
|
1410
|
+
logger.error(f"Failed to list sub-issues: {e}")
|
|
1411
|
+
return []
|
|
1412
|
+
|
|
1413
|
+
def add_sub_issue(
|
|
1414
|
+
self,
|
|
1415
|
+
parent_issue_number: int,
|
|
1416
|
+
child_issue_number: int,
|
|
1417
|
+
owner: Optional[str] = None,
|
|
1418
|
+
repo: Optional[str] = None,
|
|
1419
|
+
) -> Dict[str, Any]:
|
|
1420
|
+
"""
|
|
1421
|
+
Add an existing issue as a sub-issue to a parent.
|
|
1422
|
+
|
|
1423
|
+
Args:
|
|
1424
|
+
parent_issue_number: Parent issue number
|
|
1425
|
+
child_issue_number: Child issue number to add as sub-issue
|
|
1426
|
+
owner: Repository owner
|
|
1427
|
+
repo: Repository name
|
|
1428
|
+
|
|
1429
|
+
Returns:
|
|
1430
|
+
Result with parent and child info
|
|
1431
|
+
"""
|
|
1432
|
+
owner = owner or self.default_owner
|
|
1433
|
+
repo = repo or self.default_repo
|
|
1434
|
+
|
|
1435
|
+
if not owner or not repo:
|
|
1436
|
+
raise ValueError("Repository owner and name must be specified")
|
|
1437
|
+
|
|
1438
|
+
# First, get the child issue's internal ID (required by API)
|
|
1439
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1440
|
+
child_issue = gh_repo.get_issue(child_issue_number)
|
|
1441
|
+
child_id = child_issue.id
|
|
1442
|
+
|
|
1443
|
+
try:
|
|
1444
|
+
with httpx.Client() as client:
|
|
1445
|
+
response = client.post(
|
|
1446
|
+
f"https://api.github.com/repos/{owner}/{repo}/issues/{parent_issue_number}/sub_issues",
|
|
1447
|
+
headers={
|
|
1448
|
+
"Authorization": f"Bearer {self.token}",
|
|
1449
|
+
"Accept": "application/vnd.github+json",
|
|
1450
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1451
|
+
},
|
|
1452
|
+
json={"sub_issue_id": child_id},
|
|
1453
|
+
timeout=30.0,
|
|
1454
|
+
)
|
|
1455
|
+
response.raise_for_status()
|
|
1456
|
+
|
|
1457
|
+
return {
|
|
1458
|
+
"success": True,
|
|
1459
|
+
"parent_issue": parent_issue_number,
|
|
1460
|
+
"child_issue": child_issue_number,
|
|
1461
|
+
"child_id": child_id,
|
|
1462
|
+
}
|
|
1463
|
+
except httpx.HTTPStatusError as e:
|
|
1464
|
+
logger.error(f"Failed to add sub-issue: HTTP {e.response.status_code}")
|
|
1465
|
+
error_data = e.response.json() if e.response.content else {}
|
|
1466
|
+
raise GithubException(
|
|
1467
|
+
e.response.status_code,
|
|
1468
|
+
error_data,
|
|
1469
|
+
message=f"Failed to add #{child_issue_number} as sub-issue of #{parent_issue_number}",
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
def remove_sub_issue(
|
|
1473
|
+
self,
|
|
1474
|
+
parent_issue_number: int,
|
|
1475
|
+
child_issue_number: int,
|
|
1476
|
+
owner: Optional[str] = None,
|
|
1477
|
+
repo: Optional[str] = None,
|
|
1478
|
+
) -> Dict[str, Any]:
|
|
1479
|
+
"""
|
|
1480
|
+
Remove a sub-issue from a parent.
|
|
1481
|
+
|
|
1482
|
+
Args:
|
|
1483
|
+
parent_issue_number: Parent issue number
|
|
1484
|
+
child_issue_number: Child issue number to remove
|
|
1485
|
+
owner: Repository owner
|
|
1486
|
+
repo: Repository name
|
|
1487
|
+
|
|
1488
|
+
Returns:
|
|
1489
|
+
Result with parent and child info
|
|
1490
|
+
"""
|
|
1491
|
+
owner = owner or self.default_owner
|
|
1492
|
+
repo = repo or self.default_repo
|
|
1493
|
+
|
|
1494
|
+
if not owner or not repo:
|
|
952
1495
|
raise ValueError("Repository owner and name must be specified")
|
|
953
1496
|
|
|
954
|
-
# Get the child issue's internal ID
|
|
955
|
-
gh_repo = self._get_repo(owner, repo)
|
|
956
|
-
child_issue = gh_repo.get_issue(child_issue_number)
|
|
957
|
-
child_id = child_issue.id
|
|
1497
|
+
# Get the child issue's internal ID
|
|
1498
|
+
gh_repo = self._get_repo(owner, repo)
|
|
1499
|
+
child_issue = gh_repo.get_issue(child_issue_number)
|
|
1500
|
+
child_id = child_issue.id
|
|
1501
|
+
|
|
1502
|
+
try:
|
|
1503
|
+
with httpx.Client() as client:
|
|
1504
|
+
response = client.delete(
|
|
1505
|
+
f"https://api.github.com/repos/{owner}/{repo}/issues/{parent_issue_number}/sub_issues/{child_id}",
|
|
1506
|
+
headers={
|
|
1507
|
+
"Authorization": f"Bearer {self.token}",
|
|
1508
|
+
"Accept": "application/vnd.github+json",
|
|
1509
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1510
|
+
},
|
|
1511
|
+
timeout=30.0,
|
|
1512
|
+
)
|
|
1513
|
+
response.raise_for_status()
|
|
1514
|
+
|
|
1515
|
+
return {
|
|
1516
|
+
"success": True,
|
|
1517
|
+
"parent_issue": parent_issue_number,
|
|
1518
|
+
"child_issue": child_issue_number,
|
|
1519
|
+
"removed": True,
|
|
1520
|
+
}
|
|
1521
|
+
except httpx.HTTPStatusError as e:
|
|
1522
|
+
logger.error(f"Failed to remove sub-issue: HTTP {e.response.status_code}")
|
|
1523
|
+
error_data = e.response.json() if e.response.content else {}
|
|
1524
|
+
raise GithubException(
|
|
1525
|
+
e.response.status_code,
|
|
1526
|
+
error_data,
|
|
1527
|
+
message=f"Failed to remove #{child_issue_number} from #{parent_issue_number}",
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
# ========================================================================
|
|
1531
|
+
# Search Operations (for Appraisals)
|
|
1532
|
+
# ========================================================================
|
|
1533
|
+
|
|
1534
|
+
def search_merged_prs(
|
|
1535
|
+
self,
|
|
1536
|
+
author: Optional[str] = None,
|
|
1537
|
+
since_date: Optional[str] = None,
|
|
1538
|
+
org: Optional[str] = None,
|
|
1539
|
+
repo: Optional[str] = None,
|
|
1540
|
+
limit: int = 100,
|
|
1541
|
+
detail_level: str = "summary",
|
|
1542
|
+
) -> List[Dict[str, Any]]:
|
|
1543
|
+
"""
|
|
1544
|
+
Search for merged pull requests using GitHub Search API.
|
|
1545
|
+
|
|
1546
|
+
Ideal for gathering contribution data for appraisals/reviews.
|
|
1547
|
+
|
|
1548
|
+
Args:
|
|
1549
|
+
author: GitHub username to filter by
|
|
1550
|
+
since_date: ISO date string (YYYY-MM-DD) - only PRs merged after this date
|
|
1551
|
+
org: GitHub org to search within
|
|
1552
|
+
repo: Specific repo in "owner/repo" format (overrides org if specified)
|
|
1553
|
+
limit: Maximum PRs to return (max 100 per page)
|
|
1554
|
+
detail_level: 'summary' for minimal fields, 'full' for all fields
|
|
1555
|
+
|
|
1556
|
+
Returns:
|
|
1557
|
+
List of merged PR dicts. Summary includes: number, title, merged_at,
|
|
1558
|
+
repo, owner, html_url, author. Full adds: body, labels.
|
|
1559
|
+
"""
|
|
1560
|
+
# Build search query
|
|
1561
|
+
query_parts = ["is:pr", "is:merged"]
|
|
1562
|
+
|
|
1563
|
+
if author:
|
|
1564
|
+
query_parts.append(f"author:{author}")
|
|
1565
|
+
|
|
1566
|
+
if since_date:
|
|
1567
|
+
query_parts.append(f"merged:>={since_date}")
|
|
1568
|
+
|
|
1569
|
+
# repo takes precedence over org
|
|
1570
|
+
if repo:
|
|
1571
|
+
query_parts.append(f"repo:{repo}")
|
|
1572
|
+
elif org:
|
|
1573
|
+
query_parts.append(f"org:{org}")
|
|
1574
|
+
|
|
1575
|
+
query = " ".join(query_parts)
|
|
1576
|
+
|
|
1577
|
+
try:
|
|
1578
|
+
with httpx.Client() as client:
|
|
1579
|
+
response = client.get(
|
|
1580
|
+
"https://api.github.com/search/issues",
|
|
1581
|
+
headers={
|
|
1582
|
+
"Authorization": f"Bearer {self.token}",
|
|
1583
|
+
"Accept": "application/vnd.github+json",
|
|
1584
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1585
|
+
},
|
|
1586
|
+
params={
|
|
1587
|
+
"q": query,
|
|
1588
|
+
"sort": "updated",
|
|
1589
|
+
"order": "desc",
|
|
1590
|
+
"per_page": min(limit, 100),
|
|
1591
|
+
},
|
|
1592
|
+
timeout=30.0,
|
|
1593
|
+
)
|
|
1594
|
+
response.raise_for_status()
|
|
1595
|
+
data = response.json()
|
|
1596
|
+
|
|
1597
|
+
# Convert to simplified format
|
|
1598
|
+
prs = []
|
|
1599
|
+
for item in data.get("items", [])[:limit]:
|
|
1600
|
+
# Extract repo info from repository_url
|
|
1601
|
+
# Format: https://api.github.com/repos/owner/repo
|
|
1602
|
+
repo_url_parts = item.get("repository_url", "").split("/")
|
|
1603
|
+
repo_owner = repo_url_parts[-2] if len(repo_url_parts) >= 2 else ""
|
|
1604
|
+
repo_name = repo_url_parts[-1] if len(repo_url_parts) >= 1 else ""
|
|
1605
|
+
|
|
1606
|
+
if detail_level == "full":
|
|
1607
|
+
prs.append(
|
|
1608
|
+
{
|
|
1609
|
+
"number": item["number"],
|
|
1610
|
+
"title": item["title"],
|
|
1611
|
+
"body": item.get("body") or "",
|
|
1612
|
+
"merged_at": item.get("pull_request", {}).get("merged_at"),
|
|
1613
|
+
"html_url": item["html_url"],
|
|
1614
|
+
"labels": [
|
|
1615
|
+
label["name"] for label in item.get("labels", [])
|
|
1616
|
+
],
|
|
1617
|
+
"repo": repo_name,
|
|
1618
|
+
"owner": repo_owner,
|
|
1619
|
+
"author": item.get("user", {}).get("login", "unknown"),
|
|
1620
|
+
}
|
|
1621
|
+
)
|
|
1622
|
+
else:
|
|
1623
|
+
# Summary: skip body and labels
|
|
1624
|
+
prs.append(
|
|
1625
|
+
{
|
|
1626
|
+
"number": item["number"],
|
|
1627
|
+
"title": item["title"],
|
|
1628
|
+
"merged_at": item.get("pull_request", {}).get("merged_at"),
|
|
1629
|
+
"html_url": item["html_url"],
|
|
1630
|
+
"repo": repo_name,
|
|
1631
|
+
"owner": repo_owner,
|
|
1632
|
+
"author": item.get("user", {}).get("login", "unknown"),
|
|
1633
|
+
}
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
return prs
|
|
1637
|
+
|
|
1638
|
+
except httpx.HTTPStatusError as e:
|
|
1639
|
+
logger.error(f"Failed to search PRs: HTTP {e.response.status_code}")
|
|
1640
|
+
raise GithubException(e.response.status_code, e.response.json())
|
|
1641
|
+
except Exception as e:
|
|
1642
|
+
logger.error(f"Failed to search PRs: {e}")
|
|
1643
|
+
raise
|
|
1644
|
+
|
|
1645
|
+
def fetch_prs_parallel(
|
|
1646
|
+
self,
|
|
1647
|
+
pr_refs: List[Dict[str, Any]],
|
|
1648
|
+
max_workers: int = 10,
|
|
1649
|
+
) -> List[Dict[str, Any]]:
|
|
1650
|
+
"""
|
|
1651
|
+
Fetch full PR details for multiple PRs in parallel.
|
|
1652
|
+
|
|
1653
|
+
Args:
|
|
1654
|
+
pr_refs: List of dicts with 'owner', 'repo', 'number' keys
|
|
1655
|
+
max_workers: Max concurrent requests (default: 10)
|
|
1656
|
+
|
|
1657
|
+
Returns:
|
|
1658
|
+
List of full PR details with stats (additions, deletions, files)
|
|
1659
|
+
"""
|
|
1660
|
+
results = []
|
|
1661
|
+
errors = []
|
|
1662
|
+
|
|
1663
|
+
def fetch_single_pr(pr_ref: Dict[str, Any]) -> Dict[str, Any] | None:
|
|
1664
|
+
try:
|
|
1665
|
+
owner = pr_ref["owner"]
|
|
1666
|
+
repo = pr_ref["repo"]
|
|
1667
|
+
number = pr_ref["number"]
|
|
1668
|
+
pr = self.get_pr(number, owner=owner, repo=repo)
|
|
1669
|
+
if pr:
|
|
1670
|
+
pr_dict = pr.model_dump()
|
|
1671
|
+
# Add owner/repo for context
|
|
1672
|
+
pr_dict["owner"] = owner
|
|
1673
|
+
pr_dict["repo"] = repo
|
|
1674
|
+
return pr_dict
|
|
1675
|
+
return None
|
|
1676
|
+
except Exception as e:
|
|
1677
|
+
logger.warning(f"Failed to fetch PR {pr_ref}: {e}")
|
|
1678
|
+
return None
|
|
1679
|
+
|
|
1680
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1681
|
+
futures = {
|
|
1682
|
+
executor.submit(fetch_single_pr, pr_ref): pr_ref for pr_ref in pr_refs
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
for future in as_completed(futures):
|
|
1686
|
+
pr_ref = futures[future]
|
|
1687
|
+
try:
|
|
1688
|
+
result = future.result()
|
|
1689
|
+
if result:
|
|
1690
|
+
results.append(result)
|
|
1691
|
+
else:
|
|
1692
|
+
errors.append(pr_ref)
|
|
1693
|
+
except Exception as e:
|
|
1694
|
+
logger.error(f"Error fetching {pr_ref}: {e}")
|
|
1695
|
+
errors.append(pr_ref)
|
|
1696
|
+
|
|
1697
|
+
if errors:
|
|
1698
|
+
logger.warning(f"Failed to fetch {len(errors)} PRs: {errors[:5]}...")
|
|
1699
|
+
|
|
1700
|
+
return results
|
|
1701
|
+
|
|
1702
|
+
# ========================================================================
|
|
1703
|
+
# Project Operations (GitHub Projects V2 via GraphQL)
|
|
1704
|
+
# ========================================================================
|
|
1705
|
+
|
|
1706
|
+
def _graphql_request(self, query: str, variables: Optional[Dict] = None) -> Dict:
|
|
1707
|
+
"""
|
|
1708
|
+
Execute a GraphQL request against GitHub's API.
|
|
1709
|
+
|
|
1710
|
+
Args:
|
|
1711
|
+
query: GraphQL query or mutation string
|
|
1712
|
+
variables: Optional variables for the query
|
|
1713
|
+
|
|
1714
|
+
Returns:
|
|
1715
|
+
Response data dict
|
|
958
1716
|
|
|
1717
|
+
Raises:
|
|
1718
|
+
GithubException: If the request fails
|
|
1719
|
+
"""
|
|
959
1720
|
try:
|
|
960
1721
|
with httpx.Client() as client:
|
|
961
|
-
response = client.
|
|
962
|
-
|
|
1722
|
+
response = client.post(
|
|
1723
|
+
"https://api.github.com/graphql",
|
|
963
1724
|
headers={
|
|
964
1725
|
"Authorization": f"Bearer {self.token}",
|
|
965
1726
|
"Accept": "application/vnd.github+json",
|
|
966
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
967
1727
|
},
|
|
1728
|
+
json={"query": query, "variables": variables or {}},
|
|
968
1729
|
timeout=30.0,
|
|
969
1730
|
)
|
|
970
1731
|
response.raise_for_status()
|
|
1732
|
+
data = response.json()
|
|
971
1733
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1734
|
+
if "errors" in data:
|
|
1735
|
+
error_messages = [e.get("message", str(e)) for e in data["errors"]]
|
|
1736
|
+
raise GithubException(
|
|
1737
|
+
400, {"message": "; ".join(error_messages)}, "GraphQL Error"
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
return data.get("data", {})
|
|
1741
|
+
except httpx.HTTPStatusError as e:
|
|
1742
|
+
logger.error(f"GraphQL request failed: HTTP {e.response.status_code}")
|
|
1743
|
+
raise GithubException(e.response.status_code, e.response.json())
|
|
1744
|
+
except GithubException:
|
|
1745
|
+
raise
|
|
1746
|
+
except Exception as e:
|
|
1747
|
+
logger.error(f"GraphQL request failed: {e}")
|
|
1748
|
+
raise GithubException(500, {"message": str(e)})
|
|
1749
|
+
|
|
1750
|
+
def list_projects(
|
|
1751
|
+
self,
|
|
1752
|
+
owner: Optional[str] = None,
|
|
1753
|
+
is_org: bool = True,
|
|
1754
|
+
limit: int = 20,
|
|
1755
|
+
) -> List[Dict[str, Any]]:
|
|
1756
|
+
"""
|
|
1757
|
+
List GitHub Projects V2 for an organization or user.
|
|
1758
|
+
|
|
1759
|
+
Args:
|
|
1760
|
+
owner: Organization or user name. Uses default_owner if not specified.
|
|
1761
|
+
is_org: If True, treat owner as organization. If False, treat as user.
|
|
1762
|
+
limit: Maximum projects to return (default: 20)
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
List of project dicts with id, number, title, url, closed
|
|
1766
|
+
"""
|
|
1767
|
+
owner = owner or self.default_owner
|
|
1768
|
+
if not owner:
|
|
1769
|
+
raise ValueError("Owner must be specified for listing projects")
|
|
1770
|
+
|
|
1771
|
+
if is_org:
|
|
1772
|
+
query = """
|
|
1773
|
+
query($owner: String!, $limit: Int!) {
|
|
1774
|
+
organization(login: $owner) {
|
|
1775
|
+
projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
1776
|
+
nodes {
|
|
1777
|
+
id
|
|
1778
|
+
number
|
|
1779
|
+
title
|
|
1780
|
+
url
|
|
1781
|
+
closed
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
"""
|
|
1787
|
+
data = self._graphql_request(query, {"owner": owner, "limit": limit})
|
|
1788
|
+
org = data.get("organization")
|
|
1789
|
+
if not org:
|
|
1790
|
+
# Try as user if org lookup fails
|
|
1791
|
+
return self.list_projects(owner=owner, is_org=False, limit=limit)
|
|
1792
|
+
nodes = org.get("projectsV2", {}).get("nodes", [])
|
|
1793
|
+
else:
|
|
1794
|
+
query = """
|
|
1795
|
+
query($owner: String!, $limit: Int!) {
|
|
1796
|
+
user(login: $owner) {
|
|
1797
|
+
projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
1798
|
+
nodes {
|
|
1799
|
+
id
|
|
1800
|
+
number
|
|
1801
|
+
title
|
|
1802
|
+
url
|
|
1803
|
+
closed
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
"""
|
|
1809
|
+
data = self._graphql_request(query, {"owner": owner, "limit": limit})
|
|
1810
|
+
user = data.get("user")
|
|
1811
|
+
if not user:
|
|
1812
|
+
return []
|
|
1813
|
+
nodes = user.get("projectsV2", {}).get("nodes", [])
|
|
1814
|
+
|
|
1815
|
+
return [
|
|
1816
|
+
{
|
|
1817
|
+
"id": node["id"],
|
|
1818
|
+
"number": node["number"],
|
|
1819
|
+
"title": node["title"],
|
|
1820
|
+
"url": node["url"],
|
|
1821
|
+
"closed": node["closed"],
|
|
1822
|
+
}
|
|
1823
|
+
for node in nodes
|
|
1824
|
+
if node # Filter out None nodes
|
|
1825
|
+
]
|
|
1826
|
+
|
|
1827
|
+
def get_project_id(
|
|
1828
|
+
self,
|
|
1829
|
+
project: str,
|
|
1830
|
+
owner: Optional[str] = None,
|
|
1831
|
+
is_org: bool = True,
|
|
1832
|
+
) -> Optional[str]:
|
|
1833
|
+
"""
|
|
1834
|
+
Get the node ID of a project by number or title.
|
|
1835
|
+
|
|
1836
|
+
Args:
|
|
1837
|
+
project: Project number (as string) or title
|
|
1838
|
+
owner: Organization or user name
|
|
1839
|
+
is_org: If True, treat owner as organization
|
|
1840
|
+
|
|
1841
|
+
Returns:
|
|
1842
|
+
Project node ID or None if not found
|
|
1843
|
+
"""
|
|
1844
|
+
owner = owner or self.default_owner
|
|
1845
|
+
if not owner:
|
|
1846
|
+
raise ValueError("Owner must be specified")
|
|
1847
|
+
|
|
1848
|
+
# If project is a number, query directly
|
|
1849
|
+
if project.isdigit():
|
|
1850
|
+
project_number = int(project)
|
|
1851
|
+
if is_org:
|
|
1852
|
+
query = """
|
|
1853
|
+
query($owner: String!, $number: Int!) {
|
|
1854
|
+
organization(login: $owner) {
|
|
1855
|
+
projectV2(number: $number) {
|
|
1856
|
+
id
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
"""
|
|
1861
|
+
try:
|
|
1862
|
+
data = self._graphql_request(
|
|
1863
|
+
query, {"owner": owner, "number": project_number}
|
|
1864
|
+
)
|
|
1865
|
+
except GithubException as e:
|
|
1866
|
+
# Project not found - try as user or return None
|
|
1867
|
+
if "Could not resolve" in str(e.data.get("message", "")):
|
|
1868
|
+
return self.get_project_id(
|
|
1869
|
+
project=project, owner=owner, is_org=False
|
|
1870
|
+
)
|
|
1871
|
+
raise
|
|
1872
|
+
org = data.get("organization")
|
|
1873
|
+
if not org:
|
|
1874
|
+
# Try as user
|
|
1875
|
+
return self.get_project_id(
|
|
1876
|
+
project=project, owner=owner, is_org=False
|
|
1877
|
+
)
|
|
1878
|
+
project_data = org.get("projectV2")
|
|
1879
|
+
return project_data["id"] if project_data else None
|
|
1880
|
+
else:
|
|
1881
|
+
query = """
|
|
1882
|
+
query($owner: String!, $number: Int!) {
|
|
1883
|
+
user(login: $owner) {
|
|
1884
|
+
projectV2(number: $number) {
|
|
1885
|
+
id
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
"""
|
|
1890
|
+
try:
|
|
1891
|
+
data = self._graphql_request(
|
|
1892
|
+
query, {"owner": owner, "number": project_number}
|
|
1893
|
+
)
|
|
1894
|
+
except GithubException as e:
|
|
1895
|
+
# Project not found
|
|
1896
|
+
if "Could not resolve" in str(e.data.get("message", "")):
|
|
1897
|
+
return None
|
|
1898
|
+
raise
|
|
1899
|
+
user = data.get("user")
|
|
1900
|
+
if not user:
|
|
1901
|
+
return None
|
|
1902
|
+
project_data = user.get("projectV2")
|
|
1903
|
+
return project_data["id"] if project_data else None
|
|
1904
|
+
|
|
1905
|
+
# Project is a title - search through list
|
|
1906
|
+
projects = self.list_projects(owner=owner, is_org=is_org, limit=50)
|
|
1907
|
+
for p in projects:
|
|
1908
|
+
if p["title"].lower() == project.lower():
|
|
1909
|
+
return p["id"]
|
|
1910
|
+
|
|
1911
|
+
return None
|
|
1912
|
+
|
|
1913
|
+
def get_issue_node_id(
|
|
1914
|
+
self,
|
|
1915
|
+
issue_number: int,
|
|
1916
|
+
owner: Optional[str] = None,
|
|
1917
|
+
repo: Optional[str] = None,
|
|
1918
|
+
) -> str:
|
|
1919
|
+
"""
|
|
1920
|
+
Get the node ID of an issue (required for GraphQL mutations).
|
|
1921
|
+
|
|
1922
|
+
Args:
|
|
1923
|
+
issue_number: Issue number
|
|
1924
|
+
owner: Repository owner
|
|
1925
|
+
repo: Repository name
|
|
1926
|
+
|
|
1927
|
+
Returns:
|
|
1928
|
+
Issue node ID
|
|
1929
|
+
"""
|
|
1930
|
+
owner = owner or self.default_owner
|
|
1931
|
+
repo = repo or self.default_repo
|
|
1932
|
+
if not owner or not repo:
|
|
1933
|
+
raise ValueError("Repository owner and name must be specified")
|
|
1934
|
+
|
|
1935
|
+
query = """
|
|
1936
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
1937
|
+
repository(owner: $owner, name: $repo) {
|
|
1938
|
+
issue(number: $number) {
|
|
1939
|
+
id
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
"""
|
|
1944
|
+
data = self._graphql_request(
|
|
1945
|
+
query, {"owner": owner, "repo": repo, "number": issue_number}
|
|
1946
|
+
)
|
|
1947
|
+
repository = data.get("repository")
|
|
1948
|
+
if not repository:
|
|
1949
|
+
raise GithubException(
|
|
1950
|
+
404, {"message": f"Repository {owner}/{repo} not found"}
|
|
1951
|
+
)
|
|
1952
|
+
issue = repository.get("issue")
|
|
1953
|
+
if not issue:
|
|
1954
|
+
raise GithubException(404, {"message": f"Issue #{issue_number} not found"})
|
|
1955
|
+
return issue["id"]
|
|
1956
|
+
|
|
1957
|
+
def add_issue_to_project(
|
|
1958
|
+
self,
|
|
1959
|
+
issue_number: int,
|
|
1960
|
+
project: str,
|
|
1961
|
+
owner: Optional[str] = None,
|
|
1962
|
+
repo: Optional[str] = None,
|
|
1963
|
+
project_owner: Optional[str] = None,
|
|
1964
|
+
) -> Dict[str, Any]:
|
|
1965
|
+
"""
|
|
1966
|
+
Add an issue to a GitHub Project V2.
|
|
1967
|
+
|
|
1968
|
+
Args:
|
|
1969
|
+
issue_number: Issue number to add
|
|
1970
|
+
project: Project number (as string) or title
|
|
1971
|
+
owner: Repository owner (for the issue)
|
|
1972
|
+
repo: Repository name
|
|
1973
|
+
project_owner: Owner of the project (org or user). Defaults to repo owner.
|
|
1974
|
+
|
|
1975
|
+
Returns:
|
|
1976
|
+
Dict with project_item_id and success status
|
|
1977
|
+
"""
|
|
1978
|
+
owner = owner or self.default_owner
|
|
1979
|
+
repo = repo or self.default_repo
|
|
1980
|
+
project_owner = project_owner or owner
|
|
1981
|
+
|
|
1982
|
+
if not owner or not repo:
|
|
1983
|
+
raise ValueError("Repository owner and name must be specified")
|
|
1984
|
+
|
|
1985
|
+
# Get issue node ID
|
|
1986
|
+
issue_node_id = self.get_issue_node_id(issue_number, owner=owner, repo=repo)
|
|
1987
|
+
|
|
1988
|
+
# Get project node ID
|
|
1989
|
+
project_id = self.get_project_id(project, owner=project_owner, is_org=True)
|
|
1990
|
+
if not project_id:
|
|
1991
|
+
raise GithubException(
|
|
1992
|
+
404, {"message": f"Project '{project}' not found for {project_owner}"}
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
# Add to project using mutation
|
|
1996
|
+
mutation = """
|
|
1997
|
+
mutation($projectId: ID!, $contentId: ID!) {
|
|
1998
|
+
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
|
1999
|
+
item {
|
|
2000
|
+
id
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
"""
|
|
2005
|
+
data = self._graphql_request(
|
|
2006
|
+
mutation, {"projectId": project_id, "contentId": issue_node_id}
|
|
2007
|
+
)
|
|
2008
|
+
|
|
2009
|
+
item = data.get("addProjectV2ItemById", {}).get("item")
|
|
2010
|
+
return {
|
|
2011
|
+
"success": True,
|
|
2012
|
+
"issue_number": issue_number,
|
|
2013
|
+
"project": project,
|
|
2014
|
+
"project_item_id": item["id"] if item else None,
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
def remove_issue_from_project(
|
|
2018
|
+
self,
|
|
2019
|
+
issue_number: int,
|
|
2020
|
+
project: str,
|
|
2021
|
+
owner: Optional[str] = None,
|
|
2022
|
+
repo: Optional[str] = None,
|
|
2023
|
+
project_owner: Optional[str] = None,
|
|
2024
|
+
) -> Dict[str, Any]:
|
|
2025
|
+
"""
|
|
2026
|
+
Remove an issue from a GitHub Project V2.
|
|
2027
|
+
|
|
2028
|
+
Args:
|
|
2029
|
+
issue_number: Issue number to remove
|
|
2030
|
+
project: Project number (as string) or title
|
|
2031
|
+
owner: Repository owner (for the issue)
|
|
2032
|
+
repo: Repository name
|
|
2033
|
+
project_owner: Owner of the project (org or user). Defaults to repo owner.
|
|
2034
|
+
|
|
2035
|
+
Returns:
|
|
2036
|
+
Dict with success status
|
|
2037
|
+
"""
|
|
2038
|
+
owner = owner or self.default_owner
|
|
2039
|
+
repo = repo or self.default_repo
|
|
2040
|
+
project_owner = project_owner or owner
|
|
2041
|
+
|
|
2042
|
+
if not owner or not repo:
|
|
2043
|
+
raise ValueError("Repository owner and name must be specified")
|
|
2044
|
+
|
|
2045
|
+
# Get project node ID
|
|
2046
|
+
project_id = self.get_project_id(project, owner=project_owner, is_org=True)
|
|
2047
|
+
if not project_id:
|
|
2048
|
+
raise GithubException(
|
|
2049
|
+
404, {"message": f"Project '{project}' not found for {project_owner}"}
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
# Get issue node ID
|
|
2053
|
+
issue_node_id = self.get_issue_node_id(issue_number, owner=owner, repo=repo)
|
|
2054
|
+
|
|
2055
|
+
# First, find the project item ID for this issue
|
|
2056
|
+
query = """
|
|
2057
|
+
query($projectId: ID!, $cursor: String) {
|
|
2058
|
+
node(id: $projectId) {
|
|
2059
|
+
... on ProjectV2 {
|
|
2060
|
+
items(first: 100, after: $cursor) {
|
|
2061
|
+
nodes {
|
|
2062
|
+
id
|
|
2063
|
+
content {
|
|
2064
|
+
... on Issue {
|
|
2065
|
+
id
|
|
2066
|
+
number
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
pageInfo {
|
|
2071
|
+
hasNextPage
|
|
2072
|
+
endCursor
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
"""
|
|
2079
|
+
|
|
2080
|
+
# Paginate to find the item
|
|
2081
|
+
cursor = None
|
|
2082
|
+
item_id = None
|
|
2083
|
+
while True:
|
|
2084
|
+
data = self._graphql_request(
|
|
2085
|
+
query, {"projectId": project_id, "cursor": cursor}
|
|
2086
|
+
)
|
|
2087
|
+
node = data.get("node", {})
|
|
2088
|
+
items = node.get("items", {})
|
|
2089
|
+
|
|
2090
|
+
for item in items.get("nodes", []):
|
|
2091
|
+
content = item.get("content")
|
|
2092
|
+
if content and content.get("id") == issue_node_id:
|
|
2093
|
+
item_id = item["id"]
|
|
2094
|
+
break
|
|
2095
|
+
|
|
2096
|
+
if item_id:
|
|
2097
|
+
break
|
|
2098
|
+
|
|
2099
|
+
page_info = items.get("pageInfo", {})
|
|
2100
|
+
if not page_info.get("hasNextPage"):
|
|
2101
|
+
break
|
|
2102
|
+
cursor = page_info.get("endCursor")
|
|
2103
|
+
|
|
2104
|
+
if not item_id:
|
|
2105
|
+
raise GithubException(
|
|
2106
|
+
404,
|
|
2107
|
+
{"message": f"Issue #{issue_number} not found in project '{project}'"},
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
# Delete the item from project
|
|
2111
|
+
mutation = """
|
|
2112
|
+
mutation($projectId: ID!, $itemId: ID!) {
|
|
2113
|
+
deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) {
|
|
2114
|
+
deletedItemId
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
"""
|
|
2118
|
+
data = self._graphql_request(
|
|
2119
|
+
mutation, {"projectId": project_id, "itemId": item_id}
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
deleted_id = data.get("deleteProjectV2Item", {}).get("deletedItemId")
|
|
2123
|
+
return {
|
|
2124
|
+
"success": True,
|
|
2125
|
+
"issue_number": issue_number,
|
|
2126
|
+
"project": project,
|
|
2127
|
+
"deleted_item_id": deleted_id,
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
def get_project_fields(
|
|
2131
|
+
self,
|
|
2132
|
+
project: str,
|
|
2133
|
+
owner: Optional[str] = None,
|
|
2134
|
+
is_org: bool = True,
|
|
2135
|
+
) -> List[Dict[str, Any]]:
|
|
2136
|
+
"""
|
|
2137
|
+
Get fields for a GitHub Project V2 with options for SingleSelect fields.
|
|
2138
|
+
|
|
2139
|
+
Args:
|
|
2140
|
+
project: Project number (as string) or title
|
|
2141
|
+
owner: Organization or user name
|
|
2142
|
+
is_org: If True, treat owner as organization
|
|
2143
|
+
|
|
2144
|
+
Returns:
|
|
2145
|
+
List of field dicts with id, name, data_type, and options for SingleSelect
|
|
2146
|
+
"""
|
|
2147
|
+
owner = owner or self.default_owner
|
|
2148
|
+
if not owner:
|
|
2149
|
+
raise ValueError("Owner must be specified")
|
|
2150
|
+
|
|
2151
|
+
# Get project ID first
|
|
2152
|
+
project_id = self.get_project_id(project, owner=owner, is_org=is_org)
|
|
2153
|
+
if not project_id:
|
|
2154
|
+
raise GithubException(
|
|
2155
|
+
404, {"message": f"Project '{project}' not found for {owner}"}
|
|
2156
|
+
)
|
|
2157
|
+
|
|
2158
|
+
# Query fields with options
|
|
2159
|
+
query = """
|
|
2160
|
+
query($projectId: ID!) {
|
|
2161
|
+
node(id: $projectId) {
|
|
2162
|
+
... on ProjectV2 {
|
|
2163
|
+
fields(first: 100) {
|
|
2164
|
+
nodes {
|
|
2165
|
+
... on ProjectV2FieldCommon {
|
|
2166
|
+
id
|
|
2167
|
+
name
|
|
2168
|
+
dataType
|
|
2169
|
+
}
|
|
2170
|
+
... on ProjectV2SingleSelectField {
|
|
2171
|
+
options {
|
|
2172
|
+
id
|
|
2173
|
+
name
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
"""
|
|
2182
|
+
data = self._graphql_request(query, {"projectId": project_id})
|
|
2183
|
+
|
|
2184
|
+
node = data.get("node")
|
|
2185
|
+
if not node:
|
|
2186
|
+
return []
|
|
2187
|
+
|
|
2188
|
+
fields = []
|
|
2189
|
+
for field in node.get("fields", {}).get("nodes", []):
|
|
2190
|
+
if not field:
|
|
2191
|
+
continue
|
|
2192
|
+
|
|
2193
|
+
field_data = {
|
|
2194
|
+
"id": field.get("id"),
|
|
2195
|
+
"name": field.get("name"),
|
|
2196
|
+
"data_type": field.get("dataType"),
|
|
977
2197
|
}
|
|
978
|
-
except httpx.HTTPStatusError as e:
|
|
979
|
-
logger.error(f"Failed to remove sub-issue: HTTP {e.response.status_code}")
|
|
980
|
-
error_data = e.response.json() if e.response.content else {}
|
|
981
|
-
raise GithubException(
|
|
982
|
-
e.response.status_code,
|
|
983
|
-
error_data,
|
|
984
|
-
message=f"Failed to remove #{child_issue_number} from #{parent_issue_number}",
|
|
985
|
-
)
|
|
986
2198
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
2199
|
+
# Add options for SingleSelect fields
|
|
2200
|
+
if "options" in field:
|
|
2201
|
+
field_data["options"] = [
|
|
2202
|
+
{"id": opt["id"], "name": opt["name"]}
|
|
2203
|
+
for opt in field.get("options", [])
|
|
2204
|
+
]
|
|
990
2205
|
|
|
991
|
-
|
|
2206
|
+
fields.append(field_data)
|
|
2207
|
+
|
|
2208
|
+
return fields
|
|
2209
|
+
|
|
2210
|
+
def list_projects_with_fields(
|
|
992
2211
|
self,
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
repo: Optional[str] = None,
|
|
997
|
-
limit: int = 100,
|
|
998
|
-
detail_level: str = "summary",
|
|
2212
|
+
owner: Optional[str] = None,
|
|
2213
|
+
is_org: bool = True,
|
|
2214
|
+
limit: int = 20,
|
|
999
2215
|
) -> List[Dict[str, Any]]:
|
|
1000
2216
|
"""
|
|
1001
|
-
|
|
2217
|
+
List GitHub Projects V2 with their fields in one GraphQL call.
|
|
1002
2218
|
|
|
1003
|
-
|
|
2219
|
+
More efficient than calling list_projects + get_project_fields separately.
|
|
1004
2220
|
|
|
1005
2221
|
Args:
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
repo: Specific repo in "owner/repo" format (overrides org if specified)
|
|
1010
|
-
limit: Maximum PRs to return (max 100 per page)
|
|
1011
|
-
detail_level: 'summary' for minimal fields, 'full' for all fields
|
|
2222
|
+
owner: Organization or user name. Uses default_owner if not specified.
|
|
2223
|
+
is_org: If True, treat owner as organization. If False, treat as user.
|
|
2224
|
+
limit: Maximum projects to return (default: 20)
|
|
1012
2225
|
|
|
1013
2226
|
Returns:
|
|
1014
|
-
List of
|
|
1015
|
-
repo, owner, html_url, author. Full adds: body, labels.
|
|
2227
|
+
List of project dicts with id, number, title, url, closed, owner, fields
|
|
1016
2228
|
"""
|
|
1017
|
-
|
|
1018
|
-
|
|
2229
|
+
owner = owner or self.default_owner
|
|
2230
|
+
if not owner:
|
|
2231
|
+
raise ValueError("Owner must be specified for listing projects")
|
|
2232
|
+
|
|
2233
|
+
if is_org:
|
|
2234
|
+
query = """
|
|
2235
|
+
query($owner: String!, $limit: Int!) {
|
|
2236
|
+
organization(login: $owner) {
|
|
2237
|
+
projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
2238
|
+
nodes {
|
|
2239
|
+
id
|
|
2240
|
+
number
|
|
2241
|
+
title
|
|
2242
|
+
url
|
|
2243
|
+
closed
|
|
2244
|
+
fields(first: 50) {
|
|
2245
|
+
nodes {
|
|
2246
|
+
... on ProjectV2FieldCommon {
|
|
2247
|
+
id
|
|
2248
|
+
name
|
|
2249
|
+
dataType
|
|
2250
|
+
}
|
|
2251
|
+
... on ProjectV2SingleSelectField {
|
|
2252
|
+
options {
|
|
2253
|
+
id
|
|
2254
|
+
name
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
"""
|
|
2264
|
+
data = self._graphql_request(query, {"owner": owner, "limit": limit})
|
|
2265
|
+
org = data.get("organization")
|
|
2266
|
+
if not org:
|
|
2267
|
+
# Try as user if org lookup fails
|
|
2268
|
+
return self.list_projects_with_fields(
|
|
2269
|
+
owner=owner, is_org=False, limit=limit
|
|
2270
|
+
)
|
|
2271
|
+
nodes = org.get("projectsV2", {}).get("nodes", [])
|
|
2272
|
+
else:
|
|
2273
|
+
query = """
|
|
2274
|
+
query($owner: String!, $limit: Int!) {
|
|
2275
|
+
user(login: $owner) {
|
|
2276
|
+
projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
2277
|
+
nodes {
|
|
2278
|
+
id
|
|
2279
|
+
number
|
|
2280
|
+
title
|
|
2281
|
+
url
|
|
2282
|
+
closed
|
|
2283
|
+
fields(first: 50) {
|
|
2284
|
+
nodes {
|
|
2285
|
+
... on ProjectV2FieldCommon {
|
|
2286
|
+
id
|
|
2287
|
+
name
|
|
2288
|
+
dataType
|
|
2289
|
+
}
|
|
2290
|
+
... on ProjectV2SingleSelectField {
|
|
2291
|
+
options {
|
|
2292
|
+
id
|
|
2293
|
+
name
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
"""
|
|
2303
|
+
data = self._graphql_request(query, {"owner": owner, "limit": limit})
|
|
2304
|
+
user = data.get("user")
|
|
2305
|
+
if not user:
|
|
2306
|
+
return []
|
|
2307
|
+
nodes = user.get("projectsV2", {}).get("nodes", [])
|
|
1019
2308
|
|
|
1020
|
-
|
|
1021
|
-
|
|
2309
|
+
projects = []
|
|
2310
|
+
for node in nodes:
|
|
2311
|
+
if not node:
|
|
2312
|
+
continue
|
|
1022
2313
|
|
|
1023
|
-
|
|
1024
|
-
|
|
2314
|
+
# Parse fields
|
|
2315
|
+
fields = []
|
|
2316
|
+
for field in node.get("fields", {}).get("nodes", []):
|
|
2317
|
+
if not field:
|
|
2318
|
+
continue
|
|
1025
2319
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
2320
|
+
field_data = {
|
|
2321
|
+
"id": field.get("id"),
|
|
2322
|
+
"name": field.get("name"),
|
|
2323
|
+
"data_type": field.get("dataType"),
|
|
2324
|
+
}
|
|
1031
2325
|
|
|
1032
|
-
|
|
2326
|
+
# Add options for SingleSelect fields
|
|
2327
|
+
if "options" in field:
|
|
2328
|
+
field_data["options"] = [
|
|
2329
|
+
{"id": opt["id"], "name": opt["name"]}
|
|
2330
|
+
for opt in field.get("options", [])
|
|
2331
|
+
]
|
|
1033
2332
|
|
|
1034
|
-
|
|
1035
|
-
with httpx.Client() as client:
|
|
1036
|
-
response = client.get(
|
|
1037
|
-
"https://api.github.com/search/issues",
|
|
1038
|
-
headers={
|
|
1039
|
-
"Authorization": f"Bearer {self.token}",
|
|
1040
|
-
"Accept": "application/vnd.github+json",
|
|
1041
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
1042
|
-
},
|
|
1043
|
-
params={
|
|
1044
|
-
"q": query,
|
|
1045
|
-
"sort": "updated",
|
|
1046
|
-
"order": "desc",
|
|
1047
|
-
"per_page": min(limit, 100),
|
|
1048
|
-
},
|
|
1049
|
-
timeout=30.0,
|
|
1050
|
-
)
|
|
1051
|
-
response.raise_for_status()
|
|
1052
|
-
data = response.json()
|
|
2333
|
+
fields.append(field_data)
|
|
1053
2334
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
2335
|
+
projects.append(
|
|
2336
|
+
{
|
|
2337
|
+
"id": node["id"],
|
|
2338
|
+
"number": node["number"],
|
|
2339
|
+
"title": node["title"],
|
|
2340
|
+
"url": node["url"],
|
|
2341
|
+
"closed": node["closed"],
|
|
2342
|
+
"owner": owner,
|
|
2343
|
+
"fields": fields,
|
|
2344
|
+
}
|
|
2345
|
+
)
|
|
1062
2346
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
2347
|
+
return projects
|
|
2348
|
+
|
|
2349
|
+
def get_project_item_id(
|
|
2350
|
+
self,
|
|
2351
|
+
issue_number: int,
|
|
2352
|
+
project: str,
|
|
2353
|
+
owner: Optional[str] = None,
|
|
2354
|
+
repo: Optional[str] = None,
|
|
2355
|
+
project_owner: Optional[str] = None,
|
|
2356
|
+
) -> Optional[str]:
|
|
2357
|
+
"""
|
|
2358
|
+
Get the project item ID for an issue in a project.
|
|
2359
|
+
|
|
2360
|
+
The project item ID is required for updating field values.
|
|
2361
|
+
|
|
2362
|
+
Args:
|
|
2363
|
+
issue_number: Issue number
|
|
2364
|
+
project: Project number (as string) or title
|
|
2365
|
+
owner: Repository owner (for the issue)
|
|
2366
|
+
repo: Repository name
|
|
2367
|
+
project_owner: Owner of the project (org or user). Defaults to repo owner.
|
|
2368
|
+
|
|
2369
|
+
Returns:
|
|
2370
|
+
Project item ID or None if issue not in project
|
|
2371
|
+
"""
|
|
2372
|
+
owner = owner or self.default_owner
|
|
2373
|
+
repo = repo or self.default_repo
|
|
2374
|
+
project_owner = project_owner or owner
|
|
2375
|
+
|
|
2376
|
+
if not owner or not repo:
|
|
2377
|
+
raise ValueError("Repository owner and name must be specified")
|
|
2378
|
+
|
|
2379
|
+
# Get project ID
|
|
2380
|
+
project_id = self.get_project_id(project, owner=project_owner, is_org=True)
|
|
2381
|
+
if not project_id:
|
|
2382
|
+
raise GithubException(
|
|
2383
|
+
404, {"message": f"Project '{project}' not found for {project_owner}"}
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2386
|
+
# Get issue node ID
|
|
2387
|
+
issue_node_id = self.get_issue_node_id(issue_number, owner=owner, repo=repo)
|
|
2388
|
+
|
|
2389
|
+
# Search for the item in the project
|
|
2390
|
+
query = """
|
|
2391
|
+
query($projectId: ID!, $cursor: String) {
|
|
2392
|
+
node(id: $projectId) {
|
|
2393
|
+
... on ProjectV2 {
|
|
2394
|
+
items(first: 100, after: $cursor) {
|
|
2395
|
+
nodes {
|
|
2396
|
+
id
|
|
2397
|
+
content {
|
|
2398
|
+
... on Issue {
|
|
2399
|
+
id
|
|
2400
|
+
number
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
1077
2403
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
prs.append(
|
|
1082
|
-
{
|
|
1083
|
-
"number": item["number"],
|
|
1084
|
-
"title": item["title"],
|
|
1085
|
-
"merged_at": item.get("pull_request", {}).get("merged_at"),
|
|
1086
|
-
"html_url": item["html_url"],
|
|
1087
|
-
"repo": repo_name,
|
|
1088
|
-
"owner": repo_owner,
|
|
1089
|
-
"author": item.get("user", {}).get("login", "unknown"),
|
|
2404
|
+
pageInfo {
|
|
2405
|
+
hasNextPage
|
|
2406
|
+
endCursor
|
|
1090
2407
|
}
|
|
1091
|
-
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
"""
|
|
1092
2413
|
|
|
1093
|
-
|
|
2414
|
+
cursor = None
|
|
2415
|
+
while True:
|
|
2416
|
+
data = self._graphql_request(
|
|
2417
|
+
query, {"projectId": project_id, "cursor": cursor}
|
|
2418
|
+
)
|
|
2419
|
+
node = data.get("node", {})
|
|
2420
|
+
items = node.get("items", {})
|
|
1094
2421
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
logger.error(f"Failed to search PRs: {e}")
|
|
1100
|
-
raise
|
|
2422
|
+
for item in items.get("nodes", []):
|
|
2423
|
+
content = item.get("content")
|
|
2424
|
+
if content and content.get("id") == issue_node_id:
|
|
2425
|
+
return item["id"]
|
|
1101
2426
|
|
|
1102
|
-
|
|
2427
|
+
page_info = items.get("pageInfo", {})
|
|
2428
|
+
if not page_info.get("hasNextPage"):
|
|
2429
|
+
break
|
|
2430
|
+
cursor = page_info.get("endCursor")
|
|
2431
|
+
|
|
2432
|
+
return None
|
|
2433
|
+
|
|
2434
|
+
def update_project_item_field(
|
|
1103
2435
|
self,
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
2436
|
+
issue_number: int,
|
|
2437
|
+
project: str,
|
|
2438
|
+
field_name: str,
|
|
2439
|
+
value: str,
|
|
2440
|
+
owner: Optional[str] = None,
|
|
2441
|
+
repo: Optional[str] = None,
|
|
2442
|
+
project_owner: Optional[str] = None,
|
|
2443
|
+
) -> Dict[str, Any]:
|
|
1107
2444
|
"""
|
|
1108
|
-
|
|
2445
|
+
Update a field value for an issue in a GitHub Project V2.
|
|
2446
|
+
|
|
2447
|
+
Supports different field types:
|
|
2448
|
+
- SINGLE_SELECT: value is the option name (e.g., "In Progress")
|
|
2449
|
+
- TEXT: value is the text content
|
|
2450
|
+
- NUMBER: value is the number as string
|
|
2451
|
+
- DATE: value is ISO date format (YYYY-MM-DD)
|
|
1109
2452
|
|
|
1110
2453
|
Args:
|
|
1111
|
-
|
|
1112
|
-
|
|
2454
|
+
issue_number: Issue number
|
|
2455
|
+
project: Project number (as string) or title
|
|
2456
|
+
field_name: Field name (e.g., "Status", "Priority")
|
|
2457
|
+
value: Field value (option name for SingleSelect, text for others)
|
|
2458
|
+
owner: Repository owner (for the issue)
|
|
2459
|
+
repo: Repository name
|
|
2460
|
+
project_owner: Owner of the project (org or user). Defaults to repo owner.
|
|
1113
2461
|
|
|
1114
2462
|
Returns:
|
|
1115
|
-
|
|
2463
|
+
Dict with success status and updated info
|
|
2464
|
+
|
|
2465
|
+
Raises:
|
|
2466
|
+
GithubException: If project, field, or option not found
|
|
1116
2467
|
"""
|
|
1117
|
-
|
|
1118
|
-
|
|
2468
|
+
owner = owner or self.default_owner
|
|
2469
|
+
repo = repo or self.default_repo
|
|
2470
|
+
project_owner = project_owner or owner
|
|
1119
2471
|
|
|
1120
|
-
|
|
2472
|
+
if not owner or not repo:
|
|
2473
|
+
raise ValueError("Repository owner and name must be specified")
|
|
2474
|
+
|
|
2475
|
+
# Get project ID
|
|
2476
|
+
project_id = self.get_project_id(project, owner=project_owner, is_org=True)
|
|
2477
|
+
if not project_id:
|
|
2478
|
+
raise GithubException(
|
|
2479
|
+
404, {"message": f"Project '{project}' not found for {project_owner}"}
|
|
2480
|
+
)
|
|
2481
|
+
|
|
2482
|
+
# Get project item ID (issue must be in project)
|
|
2483
|
+
item_id = self.get_project_item_id(
|
|
2484
|
+
issue_number=issue_number,
|
|
2485
|
+
project=project,
|
|
2486
|
+
owner=owner,
|
|
2487
|
+
repo=repo,
|
|
2488
|
+
project_owner=project_owner,
|
|
2489
|
+
)
|
|
2490
|
+
if not item_id:
|
|
2491
|
+
raise GithubException(
|
|
2492
|
+
404,
|
|
2493
|
+
{
|
|
2494
|
+
"message": f"Issue #{issue_number} not found in project '{project}'. Add it first with add_to_project."
|
|
2495
|
+
},
|
|
2496
|
+
)
|
|
2497
|
+
|
|
2498
|
+
# Get fields to find the field ID and type
|
|
2499
|
+
fields = self.get_project_fields(project, owner=project_owner, is_org=True)
|
|
2500
|
+
|
|
2501
|
+
# Find the field by name (case-insensitive)
|
|
2502
|
+
field = None
|
|
2503
|
+
for f in fields:
|
|
2504
|
+
if f["name"].lower() == field_name.lower():
|
|
2505
|
+
field = f
|
|
2506
|
+
break
|
|
2507
|
+
|
|
2508
|
+
if not field:
|
|
2509
|
+
available_fields = [f["name"] for f in fields]
|
|
2510
|
+
raise GithubException(
|
|
2511
|
+
404,
|
|
2512
|
+
{
|
|
2513
|
+
"message": f"Field '{field_name}' not found in project. Available fields: {available_fields}"
|
|
2514
|
+
},
|
|
2515
|
+
)
|
|
2516
|
+
|
|
2517
|
+
field_id = field["id"]
|
|
2518
|
+
data_type = field.get("data_type")
|
|
2519
|
+
|
|
2520
|
+
# Build the value based on field type
|
|
2521
|
+
if data_type == "SINGLE_SELECT":
|
|
2522
|
+
# Find the option ID by name
|
|
2523
|
+
options = field.get("options", [])
|
|
2524
|
+
option_id = None
|
|
2525
|
+
for opt in options:
|
|
2526
|
+
if opt["name"].lower() == value.lower():
|
|
2527
|
+
option_id = opt["id"]
|
|
2528
|
+
break
|
|
2529
|
+
|
|
2530
|
+
if not option_id:
|
|
2531
|
+
available_options = [opt["name"] for opt in options]
|
|
2532
|
+
raise GithubException(
|
|
2533
|
+
400,
|
|
2534
|
+
{
|
|
2535
|
+
"message": f"Option '{value}' not found for field '{field_name}'. Available options: {available_options}"
|
|
2536
|
+
},
|
|
2537
|
+
)
|
|
2538
|
+
|
|
2539
|
+
field_value = {"singleSelectOptionId": option_id}
|
|
2540
|
+
|
|
2541
|
+
elif data_type == "TEXT":
|
|
2542
|
+
field_value = {"text": value}
|
|
2543
|
+
|
|
2544
|
+
elif data_type == "NUMBER":
|
|
1121
2545
|
try:
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
pr_dict["repo"] = repo
|
|
1131
|
-
return pr_dict
|
|
1132
|
-
return None
|
|
1133
|
-
except Exception as e:
|
|
1134
|
-
logger.warning(f"Failed to fetch PR {pr_ref}: {e}")
|
|
1135
|
-
return None
|
|
2546
|
+
field_value = {"number": float(value)}
|
|
2547
|
+
except ValueError:
|
|
2548
|
+
raise GithubException(
|
|
2549
|
+
400,
|
|
2550
|
+
{
|
|
2551
|
+
"message": f"Invalid number value '{value}' for field '{field_name}'"
|
|
2552
|
+
},
|
|
2553
|
+
)
|
|
1136
2554
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
2555
|
+
elif data_type == "DATE":
|
|
2556
|
+
field_value = {"date": value}
|
|
2557
|
+
|
|
2558
|
+
else:
|
|
2559
|
+
raise GithubException(
|
|
2560
|
+
400,
|
|
2561
|
+
{
|
|
2562
|
+
"message": f"Field type '{data_type}' not supported for updates. Supported: SINGLE_SELECT, TEXT, NUMBER, DATE"
|
|
2563
|
+
},
|
|
2564
|
+
)
|
|
2565
|
+
|
|
2566
|
+
# Execute the mutation
|
|
2567
|
+
mutation = """
|
|
2568
|
+
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
|
|
2569
|
+
updateProjectV2ItemFieldValue(input: {
|
|
2570
|
+
projectId: $projectId,
|
|
2571
|
+
itemId: $itemId,
|
|
2572
|
+
fieldId: $fieldId,
|
|
2573
|
+
value: $value
|
|
2574
|
+
}) {
|
|
2575
|
+
projectV2Item {
|
|
2576
|
+
id
|
|
2577
|
+
}
|
|
1140
2578
|
}
|
|
2579
|
+
}
|
|
2580
|
+
"""
|
|
1141
2581
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
logger.error(f"Error fetching {pr_ref}: {e}")
|
|
1152
|
-
errors.append(pr_ref)
|
|
2582
|
+
data = self._graphql_request(
|
|
2583
|
+
mutation,
|
|
2584
|
+
{
|
|
2585
|
+
"projectId": project_id,
|
|
2586
|
+
"itemId": item_id,
|
|
2587
|
+
"fieldId": field_id,
|
|
2588
|
+
"value": field_value,
|
|
2589
|
+
},
|
|
2590
|
+
)
|
|
1153
2591
|
|
|
1154
|
-
|
|
1155
|
-
|
|
2592
|
+
updated_item = data.get("updateProjectV2ItemFieldValue", {}).get(
|
|
2593
|
+
"projectV2Item"
|
|
2594
|
+
)
|
|
1156
2595
|
|
|
1157
|
-
return
|
|
2596
|
+
return {
|
|
2597
|
+
"success": True,
|
|
2598
|
+
"issue_number": issue_number,
|
|
2599
|
+
"project": project,
|
|
2600
|
+
"field": field_name,
|
|
2601
|
+
"value": value,
|
|
2602
|
+
"item_id": updated_item["id"] if updated_item else item_id,
|
|
2603
|
+
}
|