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.
- mcp_server/api_clients/github_client.py +366 -77
- mcp_server/auth/__init__.py +8 -0
- mcp_server/auth/credentials.py +350 -42
- mcp_server/tools/auth_tools.py +194 -27
- mcp_server/tools/git_tools.py +174 -0
- mcp_server/tools/github_tools.py +416 -45
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.3.0.dist-info}/METADATA +71 -61
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.3.0.dist-info}/RECORD +10 -10
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.3.0.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.1.8.dist-info → quickcall_integrations-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
188
|
+
Get the GitHub username for the authenticated user/installation.
|
|
148
189
|
|
|
149
|
-
|
|
150
|
-
|
|
190
|
+
For PAT: Returns the user's login
|
|
191
|
+
For GitHub App: Returns the installation owner
|
|
151
192
|
"""
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
201
|
-
owner=
|
|
202
|
-
full_name=
|
|
203
|
-
html_url=
|
|
204
|
-
description=
|
|
205
|
-
default_branch=
|
|
206
|
-
private=
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
mcp_server/auth/__init__.py
CHANGED
|
@@ -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
|
]
|