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.
@@ -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
- sha: Optional[str] = None,
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
- List commits.
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
- List of commits (summary or full based on detail_level)
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
- kwargs = {}
452
- if sha:
453
- kwargs["sha"] = sha
454
- if since:
455
- kwargs["since"] = datetime.fromisoformat(since.replace("Z", "+00:00"))
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
- commits = []
458
- for commit in gh_repo.get_commits(**kwargs):
459
- if len(commits) >= limit:
460
- break
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
- # Get author login
463
- commit_author = "unknown"
464
- if commit.author:
465
- commit_author = commit.author.login
466
- elif commit.commit.author:
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
- # Apply author filter
470
- if author and author.lower() != commit_author.lower():
471
- continue
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
- if detail_level == "full":
474
- commits.append(
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 commits
496
+ return self._convert_pr(pr)
497
497
 
498
- def get_commit(
498
+ def merge_pr(
499
499
  self,
500
- sha: str,
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
- ) -> Optional[Dict[str, Any]]:
506
+ ) -> Dict[str, Any]:
504
507
  """
505
- Get detailed commit information including file changes.
508
+ Merge a pull request.
506
509
 
507
510
  Args:
508
- sha: Commit SHA
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
- Commit details with files or None if not found
519
+ Dict with merge status and SHA
514
520
  """
515
- try:
516
- gh_repo = self._get_repo(owner, repo)
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
- "sha": commit.sha,
521
- "message": commit.commit.message,
522
- "author": commit.author.login if commit.author else "unknown",
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
- # Branch Operations
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
- def list_branches(
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
- limit: int = 30,
555
- ) -> List[Dict[str, Any]]:
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
- List repository branches.
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
- List of branch info dicts
593
+ Comment details
566
594
  """
567
595
  gh_repo = self._get_repo(owner, repo)
568
- branches = []
569
-
570
- for branch in gh_repo.get_branches()[:limit]:
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
- "number": issue.number,
597
- "title": issue.title,
598
- "body": issue.body,
599
- "state": issue.state,
600
- "html_url": issue.html_url,
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 list_issues(
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
- state: str = "open",
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
- List issues in a repository.
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
- List of issue summaries
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
- kwargs = {"state": state, "sort": sort, "direction": "desc"}
638
- if labels:
639
- kwargs["labels"] = labels
640
- if assignee:
641
- kwargs["assignee"] = assignee
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
- return issues
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 create_issue(
645
+ def submit_pr_review(
671
646
  self,
672
- title: str,
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
- """Create a GitHub issue."""
680
- gh_repo = self._get_repo(owner, repo)
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
- return self._issue_to_dict(issue)
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
- def close_issue(
719
- self,
720
- issue_number: int,
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
- issue = gh_repo.get_issue(issue_number)
727
- issue.edit(state="closed")
728
- return self._issue_to_dict(issue)
667
+ pr = gh_repo.get_pull(pr_number)
729
668
 
730
- def reopen_issue(
731
- self,
732
- issue_number: int,
733
- owner: Optional[str] = None,
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": comment.id,
755
- "body": comment.body,
756
- "html_url": comment.html_url,
757
- "created_at": comment.created_at.isoformat(),
758
- "issue_number": issue_number,
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 get_issue(
682
+ def convert_pr_to_draft(
762
683
  self,
763
- issue_number: int,
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
- Get detailed information about a GitHub issue.
689
+ Convert a PR to draft status using GraphQL API.
770
690
 
771
691
  Args:
772
- issue_number: Issue number
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
- Issue details including sub-issues if requested
697
+ Dict with success status
779
698
  """
780
- gh_repo = self._get_repo(owner, repo)
781
- issue = gh_repo.get_issue(issue_number)
699
+ owner = owner or self.default_owner
700
+ repo = repo or self.default_repo
782
701
 
783
- result = {
784
- "number": issue.number,
785
- "id": issue.id, # Internal ID needed for sub-issues API
786
- "title": issue.title,
787
- "body": issue.body,
788
- "state": issue.state,
789
- "html_url": issue.html_url,
790
- "labels": [label.name for label in issue.labels],
791
- "assignees": [a.login for a in issue.assignees],
792
- "created_at": issue.created_at.isoformat(),
793
- "updated_at": issue.updated_at.isoformat() if issue.updated_at else None,
794
- "closed_at": issue.closed_at.isoformat() if issue.closed_at else None,
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
- # Fetch sub-issues if requested
800
- if include_sub_issues:
801
- owner = owner or self.default_owner
802
- repo_name = repo or self.default_repo
803
- sub_issues = self.list_sub_issues(issue_number, owner=owner, repo=repo_name)
804
- result["sub_issues"] = sub_issues
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
- return result
735
+ pr_node_id = pr_data["id"]
808
736
 
809
- # ========================================================================
810
- # Sub-Issue Operations (GitHub's native sub-issues feature)
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
- def list_sub_issues(
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
- parent_issue_number: int,
759
+ pr_number: int,
816
760
  owner: Optional[str] = None,
817
761
  repo: Optional[str] = None,
818
- ) -> List[Dict[str, Any]]:
762
+ ) -> Dict[str, Any]:
819
763
  """
820
- List sub-issues of a parent issue.
764
+ Mark a draft PR as ready for review using GraphQL API.
821
765
 
822
766
  Args:
823
- parent_issue_number: Parent issue number
767
+ pr_number: PR number
824
768
  owner: Repository owner
825
769
  repo: Repository name
826
770
 
827
771
  Returns:
828
- List of sub-issue summaries
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
- try:
837
- with httpx.Client() as client:
838
- response = client.get(
839
- f"https://api.github.com/repos/{owner}/{repo}/issues/{parent_issue_number}/sub_issues",
840
- headers={
841
- "Authorization": f"Bearer {self.token}",
842
- "Accept": "application/vnd.github+json",
843
- "X-GitHub-Api-Version": "2022-11-28",
844
- },
845
- timeout=30.0,
846
- )
847
- response.raise_for_status()
848
- data = response.json()
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
- return [
851
- {
852
- "number": item["number"],
853
- "id": item["id"],
854
- "title": item["title"],
855
- "state": item["state"],
856
- "html_url": item["html_url"],
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
- for item in data
859
- ]
860
- except httpx.HTTPStatusError as e:
861
- if e.response.status_code == 404:
862
- # No sub-issues or feature not enabled
863
- return []
864
- logger.error(f"Failed to list sub-issues: HTTP {e.response.status_code}")
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
- def add_sub_issue(
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
- parent_issue_number: int,
873
- child_issue_number: int,
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 an existing issue as a sub-issue to a parent.
842
+ Add labels to a pull request.
879
843
 
880
844
  Args:
881
- parent_issue_number: Parent issue number
882
- child_issue_number: Child issue number to add as sub-issue
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
- Result with parent and child info
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
- child_issue = gh_repo.get_issue(child_issue_number)
898
- child_id = child_issue.id
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
- return {
915
- "success": True,
916
- "parent_issue": parent_issue_number,
917
- "child_issue": child_issue_number,
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 remove_sub_issue(
863
+ def remove_pr_labels(
930
864
  self,
931
- parent_issue_number: int,
932
- child_issue_number: int,
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 a sub-issue from a parent.
871
+ Remove labels from a pull request.
938
872
 
939
873
  Args:
940
- parent_issue_number: Parent issue number
941
- child_issue_number: Child issue number to remove
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
- Result with parent and child info
880
+ Dict with updated labels
947
881
  """
948
- owner = owner or self.default_owner
949
- repo = repo or self.default_repo
882
+ gh_repo = self._get_repo(owner, repo)
883
+ issue = gh_repo.get_issue(pr_number)
950
884
 
951
- if not owner or not repo:
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.delete(
962
- f"https://api.github.com/repos/{owner}/{repo}/issues/{parent_issue_number}/sub_issues/{child_id}",
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
- return {
973
- "success": True,
974
- "parent_issue": parent_issue_number,
975
- "child_issue": child_issue_number,
976
- "removed": True,
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
- # Search Operations (for Appraisals)
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
- def search_merged_prs(
2206
+ fields.append(field_data)
2207
+
2208
+ return fields
2209
+
2210
+ def list_projects_with_fields(
992
2211
  self,
993
- author: Optional[str] = None,
994
- since_date: Optional[str] = None,
995
- org: Optional[str] = None,
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
- Search for merged pull requests using GitHub Search API.
2217
+ List GitHub Projects V2 with their fields in one GraphQL call.
1002
2218
 
1003
- Ideal for gathering contribution data for appraisals/reviews.
2219
+ More efficient than calling list_projects + get_project_fields separately.
1004
2220
 
1005
2221
  Args:
1006
- author: GitHub username to filter by
1007
- since_date: ISO date string (YYYY-MM-DD) - only PRs merged after this date
1008
- org: GitHub org to search within
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 merged PR dicts. Summary includes: number, title, merged_at,
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
- # Build search query
1018
- query_parts = ["is:pr", "is:merged"]
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
- if author:
1021
- query_parts.append(f"author:{author}")
2309
+ projects = []
2310
+ for node in nodes:
2311
+ if not node:
2312
+ continue
1022
2313
 
1023
- if since_date:
1024
- query_parts.append(f"merged:>={since_date}")
2314
+ # Parse fields
2315
+ fields = []
2316
+ for field in node.get("fields", {}).get("nodes", []):
2317
+ if not field:
2318
+ continue
1025
2319
 
1026
- # repo takes precedence over org
1027
- if repo:
1028
- query_parts.append(f"repo:{repo}")
1029
- elif org:
1030
- query_parts.append(f"org:{org}")
2320
+ field_data = {
2321
+ "id": field.get("id"),
2322
+ "name": field.get("name"),
2323
+ "data_type": field.get("dataType"),
2324
+ }
1031
2325
 
1032
- query = " ".join(query_parts)
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
- try:
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
- # Convert to simplified format
1055
- prs = []
1056
- for item in data.get("items", [])[:limit]:
1057
- # Extract repo info from repository_url
1058
- # Format: https://api.github.com/repos/owner/repo
1059
- repo_url_parts = item.get("repository_url", "").split("/")
1060
- repo_owner = repo_url_parts[-2] if len(repo_url_parts) >= 2 else ""
1061
- repo_name = repo_url_parts[-1] if len(repo_url_parts) >= 1 else ""
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
- if detail_level == "full":
1064
- prs.append(
1065
- {
1066
- "number": item["number"],
1067
- "title": item["title"],
1068
- "body": item.get("body") or "",
1069
- "merged_at": item.get("pull_request", {}).get("merged_at"),
1070
- "html_url": item["html_url"],
1071
- "labels": [
1072
- label["name"] for label in item.get("labels", [])
1073
- ],
1074
- "repo": repo_name,
1075
- "owner": repo_owner,
1076
- "author": item.get("user", {}).get("login", "unknown"),
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
- else:
1080
- # Summary: skip body and labels
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
- return prs
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
- except httpx.HTTPStatusError as e:
1096
- logger.error(f"Failed to search PRs: HTTP {e.response.status_code}")
1097
- raise GithubException(e.response.status_code, e.response.json())
1098
- except Exception as e:
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
- def fetch_prs_parallel(
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
- pr_refs: List[Dict[str, Any]],
1105
- max_workers: int = 10,
1106
- ) -> List[Dict[str, Any]]:
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
- Fetch full PR details for multiple PRs in parallel.
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
- pr_refs: List of dicts with 'owner', 'repo', 'number' keys
1112
- max_workers: Max concurrent requests (default: 10)
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
- List of full PR details with stats (additions, deletions, files)
2463
+ Dict with success status and updated info
2464
+
2465
+ Raises:
2466
+ GithubException: If project, field, or option not found
1116
2467
  """
1117
- results = []
1118
- errors = []
2468
+ owner = owner or self.default_owner
2469
+ repo = repo or self.default_repo
2470
+ project_owner = project_owner or owner
1119
2471
 
1120
- def fetch_single_pr(pr_ref: Dict[str, Any]) -> Dict[str, Any] | None:
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
- owner = pr_ref["owner"]
1123
- repo = pr_ref["repo"]
1124
- number = pr_ref["number"]
1125
- pr = self.get_pr(number, owner=owner, repo=repo)
1126
- if pr:
1127
- pr_dict = pr.model_dump()
1128
- # Add owner/repo for context
1129
- pr_dict["owner"] = owner
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
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
1138
- futures = {
1139
- executor.submit(fetch_single_pr, pr_ref): pr_ref for pr_ref in pr_refs
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
- for future in as_completed(futures):
1143
- pr_ref = futures[future]
1144
- try:
1145
- result = future.result()
1146
- if result:
1147
- results.append(result)
1148
- else:
1149
- errors.append(pr_ref)
1150
- except Exception as e:
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
- if errors:
1155
- logger.warning(f"Failed to fetch {len(errors)} PRs: {errors[:5]}...")
2592
+ updated_item = data.get("updateProjectV2ItemFieldValue", {}).get(
2593
+ "projectV2Item"
2594
+ )
1156
2595
 
1157
- return results
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
+ }