quickcall-integrations 0.1.8__py3-none-any.whl → 0.3.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.
@@ -6,6 +6,7 @@ Focuses on PRs and commits for minimal implementation.
6
6
  """
7
7
 
8
8
  import logging
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
10
  from typing import List, Optional, Dict, Any
10
11
  from datetime import datetime
11
12
 
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
22
23
 
23
24
 
24
25
  class Commit(BaseModel):
25
- """Represents a GitHub commit."""
26
+ """Represents a GitHub commit (full details)."""
26
27
 
27
28
  sha: str
28
29
  message: str
@@ -31,8 +32,18 @@ class Commit(BaseModel):
31
32
  html_url: str
32
33
 
33
34
 
35
+ class CommitSummary(BaseModel):
36
+ """Represents a GitHub commit (summary - minimal fields for list operations)."""
37
+
38
+ sha: str
39
+ message_title: str # First line only
40
+ author: str
41
+ date: datetime
42
+ html_url: str
43
+
44
+
34
45
  class PullRequest(BaseModel):
35
- """Represents a GitHub pull request."""
46
+ """Represents a GitHub pull request (full details)."""
36
47
 
37
48
  number: int
38
49
  title: str
@@ -55,6 +66,18 @@ class PullRequest(BaseModel):
55
66
  reviewers: List[str] = []
56
67
 
57
68
 
69
+ class PullRequestSummary(BaseModel):
70
+ """Represents a GitHub pull request (summary - minimal fields for list operations)."""
71
+
72
+ number: int
73
+ title: str
74
+ state: str
75
+ author: str
76
+ created_at: datetime
77
+ merged_at: Optional[datetime] = None
78
+ html_url: str
79
+
80
+
58
81
  class Repository(BaseModel):
59
82
  """Represents a GitHub repository."""
60
83
 
@@ -78,6 +101,10 @@ class GitHubClient:
78
101
 
79
102
  Provides simplified interface for GitHub operations.
80
103
  Focuses on PRs and commits.
104
+
105
+ Supports both:
106
+ - GitHub App installation tokens (via QuickCall)
107
+ - Personal Access Tokens (PAT fallback)
81
108
  """
82
109
 
83
110
  def __init__(
@@ -91,16 +118,19 @@ class GitHubClient:
91
118
  Initialize GitHub API client.
92
119
 
93
120
  Args:
94
- token: GitHub installation access token
121
+ token: GitHub access token (installation token or PAT)
95
122
  default_owner: Default repository owner (optional)
96
123
  default_repo: Default repository name (optional)
97
- installation_id: GitHub App installation ID (for listing repos)
124
+ installation_id: GitHub App installation ID (None for PAT mode)
98
125
  """
99
126
  self.token = token
100
127
  self.default_owner = default_owner
101
128
  self.default_repo = default_repo
102
129
  self.installation_id = installation_id
103
130
 
131
+ # Detect if this is a PAT (no installation_id means PAT mode)
132
+ self._is_pat_mode = installation_id is None
133
+
104
134
  # Initialize PyGithub client
105
135
  auth = Auth.Token(token)
106
136
  self.gh = Github(auth=auth)
@@ -108,6 +138,11 @@ class GitHubClient:
108
138
  # Cache for repo objects
109
139
  self._repo_cache: Dict[str, Any] = {}
110
140
 
141
+ @property
142
+ def is_pat_mode(self) -> bool:
143
+ """Check if client is using PAT authentication."""
144
+ return self._is_pat_mode
145
+
111
146
  def _get_repo(self, owner: Optional[str] = None, repo: Optional[str] = None):
112
147
  """Get PyGithub repo object, using defaults if not specified."""
113
148
  owner = owner or self.default_owner
@@ -127,36 +162,50 @@ class GitHubClient:
127
162
  def health_check(self) -> bool:
128
163
  """Check if GitHub API is accessible with the token."""
129
164
  try:
130
- # Use installation/repositories endpoint - works with GitHub App tokens
131
- with httpx.Client() as client:
132
- response = client.get(
133
- "https://api.github.com/installation/repositories",
134
- headers={
135
- "Authorization": f"Bearer {self.token}",
136
- "Accept": "application/vnd.github+json",
137
- "X-GitHub-Api-Version": "2022-11-28",
138
- },
139
- params={"per_page": 1},
140
- )
141
- return response.status_code == 200
165
+ if self._is_pat_mode:
166
+ # For PAT, use /user endpoint
167
+ user = self.gh.get_user()
168
+ _ = user.login # This will trigger the API call
169
+ return True
170
+ else:
171
+ # For installation tokens, use /installation/repositories endpoint
172
+ with httpx.Client() as client:
173
+ response = client.get(
174
+ "https://api.github.com/installation/repositories",
175
+ headers={
176
+ "Authorization": f"Bearer {self.token}",
177
+ "Accept": "application/vnd.github+json",
178
+ "X-GitHub-Api-Version": "2022-11-28",
179
+ },
180
+ params={"per_page": 1},
181
+ )
182
+ return response.status_code == 200
142
183
  except Exception:
143
184
  return False
144
185
 
145
186
  def get_authenticated_user(self) -> str:
146
187
  """
147
- Get the GitHub username associated with this installation.
188
+ Get the GitHub username for the authenticated user/installation.
148
189
 
149
- Note: GitHub App installation tokens can't access /user endpoint.
150
- We return the installation owner instead.
190
+ For PAT: Returns the user's login
191
+ For GitHub App: Returns the installation owner
151
192
  """
152
- # Try to get from first repo's owner
153
- try:
154
- repos = self.list_repos(limit=1)
155
- if repos:
156
- return repos[0].owner.login
157
- except Exception:
158
- pass
159
- return "GitHub App" # Fallback
193
+ if self._is_pat_mode:
194
+ try:
195
+ user = self.gh.get_user()
196
+ return user.login
197
+ except Exception:
198
+ return self.default_owner or "unknown"
199
+ else:
200
+ # GitHub App installation tokens can't access /user endpoint
201
+ # Try to get from first repo's owner
202
+ try:
203
+ repos = self.list_repos(limit=1)
204
+ if repos:
205
+ return repos[0].owner
206
+ except Exception:
207
+ pass
208
+ return self.default_owner or "GitHub App"
160
209
 
161
210
  def close(self):
162
211
  """Close GitHub API client."""
@@ -168,7 +217,10 @@ class GitHubClient:
168
217
 
169
218
  def list_repos(self, limit: int = 20) -> List[Repository]:
170
219
  """
171
- List repositories accessible to the GitHub App installation.
220
+ List repositories accessible to the authenticated user/installation.
221
+
222
+ For PAT mode: Lists user's repositories
223
+ For GitHub App: Lists installation repositories
172
224
 
173
225
  Args:
174
226
  limit: Maximum repositories to return
@@ -177,43 +229,70 @@ class GitHubClient:
177
229
  List of repositories
178
230
  """
179
231
  repos = []
180
- try:
181
- # Installation tokens can't use PyGithub's user.get_repos() endpoint
182
- # Must use /installation/repositories endpoint directly (same as backend)
183
- # https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation
184
- with httpx.Client() as client:
185
- response = client.get(
186
- "https://api.github.com/installation/repositories",
187
- headers={
188
- "Authorization": f"Bearer {self.token}",
189
- "Accept": "application/vnd.github+json",
190
- "X-GitHub-Api-Version": "2022-11-28",
191
- },
192
- params={"per_page": limit},
193
- )
194
- response.raise_for_status()
195
- data = response.json()
196
232
 
197
- for repo_data in data.get("repositories", [])[:limit]:
233
+ if self._is_pat_mode:
234
+ # PAT mode: Use PyGithub's user.get_repos()
235
+ try:
236
+ user = self.gh.get_user()
237
+ for i, gh_repo in enumerate(user.get_repos(sort="updated")):
238
+ if i >= limit:
239
+ break
198
240
  repos.append(
199
241
  Repository(
200
- name=repo_data["name"],
201
- owner=repo_data["owner"]["login"],
202
- full_name=repo_data["full_name"],
203
- html_url=repo_data["html_url"],
204
- description=repo_data.get("description") or "",
205
- default_branch=repo_data.get("default_branch", "main"),
206
- private=repo_data.get("private", False),
242
+ name=gh_repo.name,
243
+ owner=gh_repo.owner.login,
244
+ full_name=gh_repo.full_name,
245
+ html_url=gh_repo.html_url,
246
+ description=gh_repo.description or "",
247
+ default_branch=gh_repo.default_branch,
248
+ private=gh_repo.private,
207
249
  )
208
250
  )
209
- except httpx.HTTPStatusError as e:
210
- logger.error(
211
- f"Failed to list installation repos: HTTP {e.response.status_code}"
212
- )
213
- raise GithubException(e.response.status_code, e.response.json())
214
- except Exception as e:
215
- logger.error(f"Failed to list installation repos: {e}")
216
- raise
251
+ except GithubException as e:
252
+ logger.error(f"Failed to list user repos: {e}")
253
+ raise
254
+ except Exception as e:
255
+ logger.error(f"Failed to list user repos: {e}")
256
+ raise
257
+ else:
258
+ # GitHub App mode: Use /installation/repositories endpoint
259
+ try:
260
+ # Installation tokens can't use PyGithub's user.get_repos() endpoint
261
+ # Must use /installation/repositories endpoint directly (same as backend)
262
+ # https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation
263
+ with httpx.Client() as client:
264
+ response = client.get(
265
+ "https://api.github.com/installation/repositories",
266
+ headers={
267
+ "Authorization": f"Bearer {self.token}",
268
+ "Accept": "application/vnd.github+json",
269
+ "X-GitHub-Api-Version": "2022-11-28",
270
+ },
271
+ params={"per_page": limit},
272
+ )
273
+ response.raise_for_status()
274
+ data = response.json()
275
+
276
+ for repo_data in data.get("repositories", [])[:limit]:
277
+ repos.append(
278
+ Repository(
279
+ name=repo_data["name"],
280
+ owner=repo_data["owner"]["login"],
281
+ full_name=repo_data["full_name"],
282
+ html_url=repo_data["html_url"],
283
+ description=repo_data.get("description") or "",
284
+ default_branch=repo_data.get("default_branch", "main"),
285
+ private=repo_data.get("private", False),
286
+ )
287
+ )
288
+ except httpx.HTTPStatusError as e:
289
+ logger.error(
290
+ f"Failed to list installation repos: HTTP {e.response.status_code}"
291
+ )
292
+ raise GithubException(e.response.status_code, e.response.json())
293
+ except Exception as e:
294
+ logger.error(f"Failed to list installation repos: {e}")
295
+ raise
217
296
 
218
297
  return repos
219
298
 
@@ -242,7 +321,8 @@ class GitHubClient:
242
321
  repo: Optional[str] = None,
243
322
  state: str = "open",
244
323
  limit: int = 20,
245
- ) -> List[PullRequest]:
324
+ detail_level: str = "summary",
325
+ ) -> List[PullRequest] | List[PullRequestSummary]:
246
326
  """
247
327
  List pull requests.
248
328
 
@@ -251,17 +331,27 @@ class GitHubClient:
251
331
  repo: Repository name
252
332
  state: PR state: 'open', 'closed', or 'all'
253
333
  limit: Maximum PRs to return
334
+ detail_level: 'summary' for minimal fields (~200 bytes/PR),
335
+ 'full' for all fields (~2KB/PR)
254
336
 
255
337
  Returns:
256
- List of pull requests
338
+ List of pull requests (summary or full based on detail_level)
257
339
  """
258
340
  gh_repo = self._get_repo(owner, repo)
259
341
  prs = []
260
342
 
261
- for pr in gh_repo.get_pulls(state=state, sort="updated", direction="desc")[
262
- :limit
263
- ]:
264
- prs.append(self._convert_pr(pr))
343
+ try:
344
+ pulls = gh_repo.get_pulls(state=state, sort="updated", direction="desc")
345
+ for i, pr in enumerate(pulls):
346
+ if i >= limit:
347
+ break
348
+ if detail_level == "full":
349
+ prs.append(self._convert_pr(pr))
350
+ else:
351
+ prs.append(self._convert_pr_summary(pr))
352
+ except IndexError:
353
+ # Empty repo or no PRs - return empty list
354
+ pass
265
355
 
266
356
  return prs
267
357
 
@@ -292,7 +382,7 @@ class GitHubClient:
292
382
  raise
293
383
 
294
384
  def _convert_pr(self, pr) -> PullRequest:
295
- """Convert PyGithub PullRequest to Pydantic model."""
385
+ """Convert PyGithub PullRequest to Pydantic model (full details)."""
296
386
  return PullRequest(
297
387
  number=pr.number,
298
388
  title=pr.title,
@@ -315,6 +405,18 @@ class GitHubClient:
315
405
  reviewers=[r.login for r in pr.requested_reviewers],
316
406
  )
317
407
 
408
+ def _convert_pr_summary(self, pr) -> PullRequestSummary:
409
+ """Convert PyGithub PullRequest to summary model (minimal fields)."""
410
+ return PullRequestSummary(
411
+ number=pr.number,
412
+ title=pr.title,
413
+ state=pr.state,
414
+ author=pr.user.login if pr.user else "unknown",
415
+ created_at=pr.created_at,
416
+ merged_at=pr.merged_at,
417
+ html_url=pr.html_url,
418
+ )
419
+
318
420
  # ========================================================================
319
421
  # Commit Operations
320
422
  # ========================================================================
@@ -327,7 +429,8 @@ class GitHubClient:
327
429
  author: Optional[str] = None,
328
430
  since: Optional[str] = None,
329
431
  limit: int = 20,
330
- ) -> List[Commit]:
432
+ detail_level: str = "summary",
433
+ ) -> List[Commit] | List[CommitSummary]:
331
434
  """
332
435
  List commits.
333
436
 
@@ -338,9 +441,10 @@ class GitHubClient:
338
441
  author: Filter by author username
339
442
  since: ISO datetime - only commits after this date
340
443
  limit: Maximum commits to return
444
+ detail_level: 'summary' for minimal fields, 'full' for all fields
341
445
 
342
446
  Returns:
343
- List of commits
447
+ List of commits (summary or full based on detail_level)
344
448
  """
345
449
  gh_repo = self._get_repo(owner, repo)
346
450
 
@@ -366,15 +470,28 @@ class GitHubClient:
366
470
  if author and author.lower() != commit_author.lower():
367
471
  continue
368
472
 
369
- commits.append(
370
- Commit(
371
- sha=commit.sha,
372
- message=commit.commit.message,
373
- author=commit_author,
374
- date=commit.commit.author.date,
375
- html_url=commit.html_url,
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
+ )
376
494
  )
377
- )
378
495
 
379
496
  return commits
380
497
 
@@ -460,3 +577,175 @@ class GitHubClient:
460
577
  )
461
578
 
462
579
  return branches
580
+
581
+ # ========================================================================
582
+ # Search Operations (for Appraisals)
583
+ # ========================================================================
584
+
585
+ def search_merged_prs(
586
+ self,
587
+ author: Optional[str] = None,
588
+ since_date: Optional[str] = None,
589
+ org: Optional[str] = None,
590
+ repo: Optional[str] = None,
591
+ limit: int = 100,
592
+ detail_level: str = "summary",
593
+ ) -> List[Dict[str, Any]]:
594
+ """
595
+ Search for merged pull requests using GitHub Search API.
596
+
597
+ Ideal for gathering contribution data for appraisals/reviews.
598
+
599
+ Args:
600
+ author: GitHub username to filter by
601
+ since_date: ISO date string (YYYY-MM-DD) - only PRs merged after this date
602
+ org: GitHub org to search within
603
+ repo: Specific repo in "owner/repo" format (overrides org if specified)
604
+ limit: Maximum PRs to return (max 100 per page)
605
+ detail_level: 'summary' for minimal fields, 'full' for all fields
606
+
607
+ Returns:
608
+ List of merged PR dicts. Summary includes: number, title, merged_at,
609
+ repo, owner, html_url, author. Full adds: body, labels.
610
+ """
611
+ # Build search query
612
+ query_parts = ["is:pr", "is:merged"]
613
+
614
+ if author:
615
+ query_parts.append(f"author:{author}")
616
+
617
+ if since_date:
618
+ query_parts.append(f"merged:>={since_date}")
619
+
620
+ # repo takes precedence over org
621
+ if repo:
622
+ query_parts.append(f"repo:{repo}")
623
+ elif org:
624
+ query_parts.append(f"org:{org}")
625
+
626
+ query = " ".join(query_parts)
627
+
628
+ try:
629
+ with httpx.Client() as client:
630
+ response = client.get(
631
+ "https://api.github.com/search/issues",
632
+ headers={
633
+ "Authorization": f"Bearer {self.token}",
634
+ "Accept": "application/vnd.github+json",
635
+ "X-GitHub-Api-Version": "2022-11-28",
636
+ },
637
+ params={
638
+ "q": query,
639
+ "sort": "updated",
640
+ "order": "desc",
641
+ "per_page": min(limit, 100),
642
+ },
643
+ timeout=30.0,
644
+ )
645
+ response.raise_for_status()
646
+ data = response.json()
647
+
648
+ # Convert to simplified format
649
+ prs = []
650
+ for item in data.get("items", [])[:limit]:
651
+ # Extract repo info from repository_url
652
+ # Format: https://api.github.com/repos/owner/repo
653
+ repo_url_parts = item.get("repository_url", "").split("/")
654
+ repo_owner = repo_url_parts[-2] if len(repo_url_parts) >= 2 else ""
655
+ repo_name = repo_url_parts[-1] if len(repo_url_parts) >= 1 else ""
656
+
657
+ if detail_level == "full":
658
+ prs.append(
659
+ {
660
+ "number": item["number"],
661
+ "title": item["title"],
662
+ "body": item.get("body") or "",
663
+ "merged_at": item.get("pull_request", {}).get("merged_at"),
664
+ "html_url": item["html_url"],
665
+ "labels": [
666
+ label["name"] for label in item.get("labels", [])
667
+ ],
668
+ "repo": repo_name,
669
+ "owner": repo_owner,
670
+ "author": item.get("user", {}).get("login", "unknown"),
671
+ }
672
+ )
673
+ else:
674
+ # Summary: skip body and labels
675
+ prs.append(
676
+ {
677
+ "number": item["number"],
678
+ "title": item["title"],
679
+ "merged_at": item.get("pull_request", {}).get("merged_at"),
680
+ "html_url": item["html_url"],
681
+ "repo": repo_name,
682
+ "owner": repo_owner,
683
+ "author": item.get("user", {}).get("login", "unknown"),
684
+ }
685
+ )
686
+
687
+ return prs
688
+
689
+ except httpx.HTTPStatusError as e:
690
+ logger.error(f"Failed to search PRs: HTTP {e.response.status_code}")
691
+ raise GithubException(e.response.status_code, e.response.json())
692
+ except Exception as e:
693
+ logger.error(f"Failed to search PRs: {e}")
694
+ raise
695
+
696
+ def fetch_prs_parallel(
697
+ self,
698
+ pr_refs: List[Dict[str, Any]],
699
+ max_workers: int = 10,
700
+ ) -> List[Dict[str, Any]]:
701
+ """
702
+ Fetch full PR details for multiple PRs in parallel.
703
+
704
+ Args:
705
+ pr_refs: List of dicts with 'owner', 'repo', 'number' keys
706
+ max_workers: Max concurrent requests (default: 10)
707
+
708
+ Returns:
709
+ List of full PR details with stats (additions, deletions, files)
710
+ """
711
+ results = []
712
+ errors = []
713
+
714
+ def fetch_single_pr(pr_ref: Dict[str, Any]) -> Dict[str, Any] | None:
715
+ try:
716
+ owner = pr_ref["owner"]
717
+ repo = pr_ref["repo"]
718
+ number = pr_ref["number"]
719
+ pr = self.get_pr(number, owner=owner, repo=repo)
720
+ if pr:
721
+ pr_dict = pr.model_dump()
722
+ # Add owner/repo for context
723
+ pr_dict["owner"] = owner
724
+ pr_dict["repo"] = repo
725
+ return pr_dict
726
+ return None
727
+ except Exception as e:
728
+ logger.warning(f"Failed to fetch PR {pr_ref}: {e}")
729
+ return None
730
+
731
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
732
+ futures = {
733
+ executor.submit(fetch_single_pr, pr_ref): pr_ref for pr_ref in pr_refs
734
+ }
735
+
736
+ for future in as_completed(futures):
737
+ pr_ref = futures[future]
738
+ try:
739
+ result = future.result()
740
+ if result:
741
+ results.append(result)
742
+ else:
743
+ errors.append(pr_ref)
744
+ except Exception as e:
745
+ logger.error(f"Error fetching {pr_ref}: {e}")
746
+ errors.append(pr_ref)
747
+
748
+ if errors:
749
+ logger.warning(f"Failed to fetch {len(errors)} PRs: {errors[:5]}...")
750
+
751
+ return results
@@ -3,22 +3,30 @@ QuickCall Authentication Module
3
3
 
4
4
  Handles OAuth device flow authentication for CLI/MCP clients.
5
5
  Stores credentials locally and fetches fresh tokens from quickcall.dev API.
6
+
7
+ Also supports GitHub PAT fallback for users without GitHub App access.
6
8
  """
7
9
 
8
10
  from mcp_server.auth.credentials import (
9
11
  CredentialStore,
12
+ GitHubPATCredentials,
10
13
  get_credential_store,
11
14
  is_authenticated,
12
15
  get_credentials,
13
16
  clear_credentials,
17
+ get_github_pat,
18
+ get_github_pat_username,
14
19
  )
15
20
  from mcp_server.auth.device_flow import DeviceFlowAuth
16
21
 
17
22
  __all__ = [
18
23
  "CredentialStore",
24
+ "GitHubPATCredentials",
19
25
  "get_credential_store",
20
26
  "is_authenticated",
21
27
  "get_credentials",
22
28
  "clear_credentials",
29
+ "get_github_pat",
30
+ "get_github_pat_username",
23
31
  "DeviceFlowAuth",
24
32
  ]