quickcall-integrations 0.1.7__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_server/__init__.py CHANGED
@@ -3,4 +3,4 @@ MCP Server for QuickCall
3
3
  GitHub integration tools for AI assistant
4
4
  """
5
5
 
6
- __version__ = "0.1.7"
6
+ __version__ = "0.1.8"
@@ -78,6 +78,10 @@ class GitHubClient:
78
78
 
79
79
  Provides simplified interface for GitHub operations.
80
80
  Focuses on PRs and commits.
81
+
82
+ Supports both:
83
+ - GitHub App installation tokens (via QuickCall)
84
+ - Personal Access Tokens (PAT fallback)
81
85
  """
82
86
 
83
87
  def __init__(
@@ -91,16 +95,19 @@ class GitHubClient:
91
95
  Initialize GitHub API client.
92
96
 
93
97
  Args:
94
- token: GitHub installation access token
98
+ token: GitHub access token (installation token or PAT)
95
99
  default_owner: Default repository owner (optional)
96
100
  default_repo: Default repository name (optional)
97
- installation_id: GitHub App installation ID (for listing repos)
101
+ installation_id: GitHub App installation ID (None for PAT mode)
98
102
  """
99
103
  self.token = token
100
104
  self.default_owner = default_owner
101
105
  self.default_repo = default_repo
102
106
  self.installation_id = installation_id
103
107
 
108
+ # Detect if this is a PAT (no installation_id means PAT mode)
109
+ self._is_pat_mode = installation_id is None
110
+
104
111
  # Initialize PyGithub client
105
112
  auth = Auth.Token(token)
106
113
  self.gh = Github(auth=auth)
@@ -108,6 +115,11 @@ class GitHubClient:
108
115
  # Cache for repo objects
109
116
  self._repo_cache: Dict[str, Any] = {}
110
117
 
118
+ @property
119
+ def is_pat_mode(self) -> bool:
120
+ """Check if client is using PAT authentication."""
121
+ return self._is_pat_mode
122
+
111
123
  def _get_repo(self, owner: Optional[str] = None, repo: Optional[str] = None):
112
124
  """Get PyGithub repo object, using defaults if not specified."""
113
125
  owner = owner or self.default_owner
@@ -127,36 +139,50 @@ class GitHubClient:
127
139
  def health_check(self) -> bool:
128
140
  """Check if GitHub API is accessible with the token."""
129
141
  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
142
+ if self._is_pat_mode:
143
+ # For PAT, use /user endpoint
144
+ user = self.gh.get_user()
145
+ _ = user.login # This will trigger the API call
146
+ return True
147
+ else:
148
+ # For installation tokens, use /installation/repositories endpoint
149
+ with httpx.Client() as client:
150
+ response = client.get(
151
+ "https://api.github.com/installation/repositories",
152
+ headers={
153
+ "Authorization": f"Bearer {self.token}",
154
+ "Accept": "application/vnd.github+json",
155
+ "X-GitHub-Api-Version": "2022-11-28",
156
+ },
157
+ params={"per_page": 1},
158
+ )
159
+ return response.status_code == 200
142
160
  except Exception:
143
161
  return False
144
162
 
145
163
  def get_authenticated_user(self) -> str:
146
164
  """
147
- Get the GitHub username associated with this installation.
165
+ Get the GitHub username for the authenticated user/installation.
148
166
 
149
- Note: GitHub App installation tokens can't access /user endpoint.
150
- We return the installation owner instead.
167
+ For PAT: Returns the user's login
168
+ For GitHub App: Returns the installation owner
151
169
  """
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
170
+ if self._is_pat_mode:
171
+ try:
172
+ user = self.gh.get_user()
173
+ return user.login
174
+ except Exception:
175
+ return self.default_owner or "unknown"
176
+ else:
177
+ # GitHub App installation tokens can't access /user endpoint
178
+ # Try to get from first repo's owner
179
+ try:
180
+ repos = self.list_repos(limit=1)
181
+ if repos:
182
+ return repos[0].owner
183
+ except Exception:
184
+ pass
185
+ return self.default_owner or "GitHub App"
160
186
 
161
187
  def close(self):
162
188
  """Close GitHub API client."""
@@ -168,7 +194,10 @@ class GitHubClient:
168
194
 
169
195
  def list_repos(self, limit: int = 20) -> List[Repository]:
170
196
  """
171
- List repositories accessible to the GitHub App installation.
197
+ List repositories accessible to the authenticated user/installation.
198
+
199
+ For PAT mode: Lists user's repositories
200
+ For GitHub App: Lists installation repositories
172
201
 
173
202
  Args:
174
203
  limit: Maximum repositories to return
@@ -177,43 +206,70 @@ class GitHubClient:
177
206
  List of repositories
178
207
  """
179
208
  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
209
 
197
- for repo_data in data.get("repositories", [])[:limit]:
210
+ if self._is_pat_mode:
211
+ # PAT mode: Use PyGithub's user.get_repos()
212
+ try:
213
+ user = self.gh.get_user()
214
+ for i, gh_repo in enumerate(user.get_repos(sort="updated")):
215
+ if i >= limit:
216
+ break
198
217
  repos.append(
199
218
  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),
219
+ name=gh_repo.name,
220
+ owner=gh_repo.owner.login,
221
+ full_name=gh_repo.full_name,
222
+ html_url=gh_repo.html_url,
223
+ description=gh_repo.description or "",
224
+ default_branch=gh_repo.default_branch,
225
+ private=gh_repo.private,
207
226
  )
208
227
  )
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
228
+ except GithubException as e:
229
+ logger.error(f"Failed to list user repos: {e}")
230
+ raise
231
+ except Exception as e:
232
+ logger.error(f"Failed to list user repos: {e}")
233
+ raise
234
+ else:
235
+ # GitHub App mode: Use /installation/repositories endpoint
236
+ try:
237
+ # Installation tokens can't use PyGithub's user.get_repos() endpoint
238
+ # Must use /installation/repositories endpoint directly (same as backend)
239
+ # https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation
240
+ with httpx.Client() as client:
241
+ response = client.get(
242
+ "https://api.github.com/installation/repositories",
243
+ headers={
244
+ "Authorization": f"Bearer {self.token}",
245
+ "Accept": "application/vnd.github+json",
246
+ "X-GitHub-Api-Version": "2022-11-28",
247
+ },
248
+ params={"per_page": limit},
249
+ )
250
+ response.raise_for_status()
251
+ data = response.json()
252
+
253
+ for repo_data in data.get("repositories", [])[:limit]:
254
+ repos.append(
255
+ Repository(
256
+ name=repo_data["name"],
257
+ owner=repo_data["owner"]["login"],
258
+ full_name=repo_data["full_name"],
259
+ html_url=repo_data["html_url"],
260
+ description=repo_data.get("description") or "",
261
+ default_branch=repo_data.get("default_branch", "main"),
262
+ private=repo_data.get("private", False),
263
+ )
264
+ )
265
+ except httpx.HTTPStatusError as e:
266
+ logger.error(
267
+ f"Failed to list installation repos: HTTP {e.response.status_code}"
268
+ )
269
+ raise GithubException(e.response.status_code, e.response.json())
270
+ except Exception as e:
271
+ logger.error(f"Failed to list installation repos: {e}")
272
+ raise
217
273
 
218
274
  return repos
219
275
 
@@ -258,10 +314,15 @@ class GitHubClient:
258
314
  gh_repo = self._get_repo(owner, repo)
259
315
  prs = []
260
316
 
261
- for pr in gh_repo.get_pulls(state=state, sort="updated", direction="desc")[
262
- :limit
263
- ]:
264
- prs.append(self._convert_pr(pr))
317
+ try:
318
+ pulls = gh_repo.get_pulls(state=state, sort="updated", direction="desc")
319
+ for i, pr in enumerate(pulls):
320
+ if i >= limit:
321
+ break
322
+ prs.append(self._convert_pr(pr))
323
+ except IndexError:
324
+ # Empty repo or no PRs - return empty list
325
+ pass
265
326
 
266
327
  return prs
267
328
 
@@ -460,3 +521,100 @@ class GitHubClient:
460
521
  )
461
522
 
462
523
  return branches
524
+
525
+ # ========================================================================
526
+ # Search Operations (for Appraisals)
527
+ # ========================================================================
528
+
529
+ def search_merged_prs(
530
+ self,
531
+ author: Optional[str] = None,
532
+ since_date: Optional[str] = None,
533
+ org: Optional[str] = None,
534
+ repo: Optional[str] = None,
535
+ limit: int = 100,
536
+ ) -> List[Dict[str, Any]]:
537
+ """
538
+ Search for merged pull requests using GitHub Search API.
539
+
540
+ Ideal for gathering contribution data for appraisals/reviews.
541
+
542
+ Args:
543
+ author: GitHub username to filter by
544
+ since_date: ISO date string (YYYY-MM-DD) - only PRs merged after this date
545
+ org: GitHub org to search within
546
+ repo: Specific repo in "owner/repo" format (overrides org if specified)
547
+ limit: Maximum PRs to return (max 100 per page)
548
+
549
+ Returns:
550
+ List of merged PR dicts with: number, title, body, merged_at,
551
+ labels, repo, owner, html_url
552
+ """
553
+ # Build search query
554
+ query_parts = ["is:pr", "is:merged"]
555
+
556
+ if author:
557
+ query_parts.append(f"author:{author}")
558
+
559
+ if since_date:
560
+ query_parts.append(f"merged:>={since_date}")
561
+
562
+ # repo takes precedence over org
563
+ if repo:
564
+ query_parts.append(f"repo:{repo}")
565
+ elif org:
566
+ query_parts.append(f"org:{org}")
567
+
568
+ query = " ".join(query_parts)
569
+
570
+ try:
571
+ with httpx.Client() as client:
572
+ response = client.get(
573
+ "https://api.github.com/search/issues",
574
+ headers={
575
+ "Authorization": f"Bearer {self.token}",
576
+ "Accept": "application/vnd.github+json",
577
+ "X-GitHub-Api-Version": "2022-11-28",
578
+ },
579
+ params={
580
+ "q": query,
581
+ "sort": "updated",
582
+ "order": "desc",
583
+ "per_page": min(limit, 100),
584
+ },
585
+ timeout=30.0,
586
+ )
587
+ response.raise_for_status()
588
+ data = response.json()
589
+
590
+ # Convert to simplified format
591
+ prs = []
592
+ for item in data.get("items", [])[:limit]:
593
+ # Extract repo info from repository_url
594
+ # Format: https://api.github.com/repos/owner/repo
595
+ repo_url_parts = item.get("repository_url", "").split("/")
596
+ repo_owner = repo_url_parts[-2] if len(repo_url_parts) >= 2 else ""
597
+ repo_name = repo_url_parts[-1] if len(repo_url_parts) >= 1 else ""
598
+
599
+ prs.append(
600
+ {
601
+ "number": item["number"],
602
+ "title": item["title"],
603
+ "body": item.get("body") or "",
604
+ "merged_at": item.get("pull_request", {}).get("merged_at"),
605
+ "html_url": item["html_url"],
606
+ "labels": [label["name"] for label in item.get("labels", [])],
607
+ "repo": repo_name,
608
+ "owner": repo_owner,
609
+ "author": item.get("user", {}).get("login", "unknown"),
610
+ }
611
+ )
612
+
613
+ return prs
614
+
615
+ except httpx.HTTPStatusError as e:
616
+ logger.error(f"Failed to search PRs: HTTP {e.response.status_code}")
617
+ raise GithubException(e.response.status_code, e.response.json())
618
+ except Exception as e:
619
+ logger.error(f"Failed to search PRs: {e}")
620
+ raise
@@ -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
  ]