quickcall-integrations 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -0,0 +1,6 @@
1
+ """API clients for external services."""
2
+
3
+ from mcp_server.api_clients.github_client import GitHubClient
4
+ from mcp_server.api_clients.slack_client import SlackClient
5
+
6
+ __all__ = ["GitHubClient", "SlackClient"]
@@ -0,0 +1,440 @@
1
+ """
2
+ GitHub API client for MCP server.
3
+
4
+ Provides GitHub API operations using PyGithub library.
5
+ Focuses on PRs and commits for minimal implementation.
6
+ """
7
+
8
+ import logging
9
+ from typing import List, Optional, Dict, Any
10
+ from datetime import datetime
11
+
12
+ from github import Github, GithubException, Auth
13
+ from pydantic import BaseModel
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # ============================================================================
20
+ # Pydantic models for GitHub data
21
+ # ============================================================================
22
+
23
+
24
+ class Commit(BaseModel):
25
+ """Represents a GitHub commit."""
26
+
27
+ sha: str
28
+ message: str
29
+ author: str
30
+ date: datetime
31
+ html_url: str
32
+
33
+
34
+ class PullRequest(BaseModel):
35
+ """Represents a GitHub pull request."""
36
+
37
+ number: int
38
+ title: str
39
+ body: Optional[str] = None
40
+ state: str # open, closed
41
+ author: str
42
+ created_at: datetime
43
+ updated_at: Optional[datetime] = None
44
+ merged_at: Optional[datetime] = None
45
+ html_url: str
46
+ head_branch: str
47
+ base_branch: str
48
+ additions: int = 0
49
+ deletions: int = 0
50
+ changed_files: int = 0
51
+ commits: int = 0
52
+ draft: bool = False
53
+ mergeable: Optional[bool] = None
54
+ labels: List[str] = []
55
+ reviewers: List[str] = []
56
+
57
+
58
+ class Repository(BaseModel):
59
+ """Represents a GitHub repository."""
60
+
61
+ name: str
62
+ owner: str
63
+ full_name: str
64
+ html_url: str
65
+ description: str = ""
66
+ default_branch: str
67
+ private: bool = False
68
+
69
+
70
+ # ============================================================================
71
+ # GitHub Client
72
+ # ============================================================================
73
+
74
+
75
+ class GitHubClient:
76
+ """
77
+ GitHub API client using PyGithub.
78
+
79
+ Provides simplified interface for GitHub operations.
80
+ Focuses on PRs and commits.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ token: str,
86
+ default_owner: Optional[str] = None,
87
+ default_repo: Optional[str] = None,
88
+ installation_id: Optional[int] = None,
89
+ ):
90
+ """
91
+ Initialize GitHub API client.
92
+
93
+ Args:
94
+ token: GitHub installation access token
95
+ default_owner: Default repository owner (optional)
96
+ default_repo: Default repository name (optional)
97
+ installation_id: GitHub App installation ID (for listing repos)
98
+ """
99
+ self.token = token
100
+ self.default_owner = default_owner
101
+ self.default_repo = default_repo
102
+ self.installation_id = installation_id
103
+
104
+ # Initialize PyGithub client
105
+ auth = Auth.Token(token)
106
+ self.gh = Github(auth=auth)
107
+
108
+ # Cache for repo objects
109
+ self._repo_cache: Dict[str, Any] = {}
110
+
111
+ def _get_repo(self, owner: Optional[str] = None, repo: Optional[str] = None):
112
+ """Get PyGithub repo object, using defaults if not specified."""
113
+ owner = owner or self.default_owner
114
+ repo = repo or self.default_repo
115
+
116
+ if not owner or not repo:
117
+ raise ValueError(
118
+ "Repository owner and name must be specified or set as defaults"
119
+ )
120
+
121
+ full_name = f"{owner}/{repo}"
122
+ if full_name not in self._repo_cache:
123
+ self._repo_cache[full_name] = self.gh.get_repo(full_name)
124
+
125
+ return self._repo_cache[full_name]
126
+
127
+ def health_check(self) -> bool:
128
+ """Check if GitHub API is accessible with the token."""
129
+ try:
130
+ self.gh.get_user().login
131
+ return True
132
+ except Exception:
133
+ return False
134
+
135
+ def get_authenticated_user(self) -> str:
136
+ """Get the username of the authenticated user."""
137
+ return self.gh.get_user().login
138
+
139
+ def close(self):
140
+ """Close GitHub API client."""
141
+ self.gh.close()
142
+
143
+ # ========================================================================
144
+ # Repository Operations
145
+ # ========================================================================
146
+
147
+ def list_repos(self, limit: int = 20) -> List[Repository]:
148
+ """
149
+ List repositories accessible to the GitHub App installation.
150
+
151
+ Args:
152
+ limit: Maximum repositories to return
153
+
154
+ Returns:
155
+ List of repositories
156
+ """
157
+ repos = []
158
+ try:
159
+ # Installation tokens can't use PyGithub's user.get_repos() endpoint
160
+ # Must use /installation/repositories endpoint directly (same as backend)
161
+ # https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation
162
+ with httpx.Client() as client:
163
+ response = client.get(
164
+ "https://api.github.com/installation/repositories",
165
+ headers={
166
+ "Authorization": f"Bearer {self.token}",
167
+ "Accept": "application/vnd.github+json",
168
+ "X-GitHub-Api-Version": "2022-11-28",
169
+ },
170
+ params={"per_page": limit},
171
+ )
172
+ response.raise_for_status()
173
+ data = response.json()
174
+
175
+ for repo_data in data.get("repositories", [])[:limit]:
176
+ repos.append(
177
+ Repository(
178
+ name=repo_data["name"],
179
+ owner=repo_data["owner"]["login"],
180
+ full_name=repo_data["full_name"],
181
+ html_url=repo_data["html_url"],
182
+ description=repo_data.get("description") or "",
183
+ default_branch=repo_data.get("default_branch", "main"),
184
+ private=repo_data.get("private", False),
185
+ )
186
+ )
187
+ except httpx.HTTPStatusError as e:
188
+ logger.error(
189
+ f"Failed to list installation repos: HTTP {e.response.status_code}"
190
+ )
191
+ raise GithubException(e.response.status_code, e.response.json())
192
+ except Exception as e:
193
+ logger.error(f"Failed to list installation repos: {e}")
194
+ raise
195
+
196
+ return repos
197
+
198
+ def get_repo_info(
199
+ self, owner: Optional[str] = None, repo: Optional[str] = None
200
+ ) -> Repository:
201
+ """Get repository information."""
202
+ gh_repo = self._get_repo(owner, repo)
203
+ return Repository(
204
+ name=gh_repo.name,
205
+ owner=gh_repo.owner.login,
206
+ full_name=gh_repo.full_name,
207
+ html_url=gh_repo.html_url,
208
+ description=gh_repo.description or "",
209
+ default_branch=gh_repo.default_branch,
210
+ private=gh_repo.private,
211
+ )
212
+
213
+ # ========================================================================
214
+ # Pull Request Operations
215
+ # ========================================================================
216
+
217
+ def list_prs(
218
+ self,
219
+ owner: Optional[str] = None,
220
+ repo: Optional[str] = None,
221
+ state: str = "open",
222
+ limit: int = 20,
223
+ ) -> List[PullRequest]:
224
+ """
225
+ List pull requests.
226
+
227
+ Args:
228
+ owner: Repository owner
229
+ repo: Repository name
230
+ state: PR state: 'open', 'closed', or 'all'
231
+ limit: Maximum PRs to return
232
+
233
+ Returns:
234
+ List of pull requests
235
+ """
236
+ gh_repo = self._get_repo(owner, repo)
237
+ prs = []
238
+
239
+ for pr in gh_repo.get_pulls(state=state, sort="updated", direction="desc")[
240
+ :limit
241
+ ]:
242
+ prs.append(self._convert_pr(pr))
243
+
244
+ return prs
245
+
246
+ def get_pr(
247
+ self,
248
+ pr_number: int,
249
+ owner: Optional[str] = None,
250
+ repo: Optional[str] = None,
251
+ ) -> Optional[PullRequest]:
252
+ """
253
+ Get a specific pull request by number.
254
+
255
+ Args:
256
+ pr_number: PR number
257
+ owner: Repository owner
258
+ repo: Repository name
259
+
260
+ Returns:
261
+ PullRequest or None if not found
262
+ """
263
+ try:
264
+ gh_repo = self._get_repo(owner, repo)
265
+ pr = gh_repo.get_pull(pr_number)
266
+ return self._convert_pr(pr)
267
+ except GithubException as e:
268
+ if e.status == 404:
269
+ return None
270
+ raise
271
+
272
+ def _convert_pr(self, pr) -> PullRequest:
273
+ """Convert PyGithub PullRequest to Pydantic model."""
274
+ return PullRequest(
275
+ number=pr.number,
276
+ title=pr.title,
277
+ body=pr.body,
278
+ state=pr.state,
279
+ author=pr.user.login if pr.user else "unknown",
280
+ created_at=pr.created_at,
281
+ updated_at=pr.updated_at,
282
+ merged_at=pr.merged_at,
283
+ html_url=pr.html_url,
284
+ head_branch=pr.head.ref,
285
+ base_branch=pr.base.ref,
286
+ additions=pr.additions,
287
+ deletions=pr.deletions,
288
+ changed_files=pr.changed_files,
289
+ commits=pr.commits,
290
+ draft=pr.draft,
291
+ mergeable=pr.mergeable,
292
+ labels=[label.name for label in pr.labels],
293
+ reviewers=[r.login for r in pr.requested_reviewers],
294
+ )
295
+
296
+ # ========================================================================
297
+ # Commit Operations
298
+ # ========================================================================
299
+
300
+ def list_commits(
301
+ self,
302
+ owner: Optional[str] = None,
303
+ repo: Optional[str] = None,
304
+ sha: Optional[str] = None,
305
+ author: Optional[str] = None,
306
+ since: Optional[str] = None,
307
+ limit: int = 20,
308
+ ) -> List[Commit]:
309
+ """
310
+ List commits.
311
+
312
+ Args:
313
+ owner: Repository owner
314
+ repo: Repository name
315
+ sha: Branch name or commit SHA to start from
316
+ author: Filter by author username
317
+ since: ISO datetime - only commits after this date
318
+ limit: Maximum commits to return
319
+
320
+ Returns:
321
+ List of commits
322
+ """
323
+ gh_repo = self._get_repo(owner, repo)
324
+
325
+ kwargs = {}
326
+ if sha:
327
+ kwargs["sha"] = sha
328
+ if since:
329
+ kwargs["since"] = datetime.fromisoformat(since.replace("Z", "+00:00"))
330
+
331
+ commits = []
332
+ for commit in gh_repo.get_commits(**kwargs):
333
+ if len(commits) >= limit:
334
+ break
335
+
336
+ # Get author login
337
+ commit_author = "unknown"
338
+ if commit.author:
339
+ commit_author = commit.author.login
340
+ elif commit.commit.author:
341
+ commit_author = commit.commit.author.name
342
+
343
+ # Apply author filter
344
+ if author and author.lower() != commit_author.lower():
345
+ continue
346
+
347
+ commits.append(
348
+ Commit(
349
+ sha=commit.sha,
350
+ message=commit.commit.message,
351
+ author=commit_author,
352
+ date=commit.commit.author.date,
353
+ html_url=commit.html_url,
354
+ )
355
+ )
356
+
357
+ return commits
358
+
359
+ def get_commit(
360
+ self,
361
+ sha: str,
362
+ owner: Optional[str] = None,
363
+ repo: Optional[str] = None,
364
+ ) -> Optional[Dict[str, Any]]:
365
+ """
366
+ Get detailed commit information including file changes.
367
+
368
+ Args:
369
+ sha: Commit SHA
370
+ owner: Repository owner
371
+ repo: Repository name
372
+
373
+ Returns:
374
+ Commit details with files or None if not found
375
+ """
376
+ try:
377
+ gh_repo = self._get_repo(owner, repo)
378
+ commit = gh_repo.get_commit(sha)
379
+
380
+ return {
381
+ "sha": commit.sha,
382
+ "message": commit.commit.message,
383
+ "author": commit.author.login if commit.author else "unknown",
384
+ "date": commit.commit.author.date.isoformat(),
385
+ "html_url": commit.html_url,
386
+ "stats": {
387
+ "additions": commit.stats.additions,
388
+ "deletions": commit.stats.deletions,
389
+ "total": commit.stats.total,
390
+ },
391
+ "files": [
392
+ {
393
+ "filename": f.filename,
394
+ "status": f.status,
395
+ "additions": f.additions,
396
+ "deletions": f.deletions,
397
+ "patch": f.patch[:1000] if f.patch else None,
398
+ }
399
+ for f in commit.files[:30] # Limit files
400
+ ],
401
+ }
402
+ except GithubException as e:
403
+ if e.status == 404:
404
+ return None
405
+ raise
406
+
407
+ # ========================================================================
408
+ # Branch Operations
409
+ # ========================================================================
410
+
411
+ def list_branches(
412
+ self,
413
+ owner: Optional[str] = None,
414
+ repo: Optional[str] = None,
415
+ limit: int = 30,
416
+ ) -> List[Dict[str, Any]]:
417
+ """
418
+ List repository branches.
419
+
420
+ Args:
421
+ owner: Repository owner
422
+ repo: Repository name
423
+ limit: Maximum branches to return
424
+
425
+ Returns:
426
+ List of branch info dicts
427
+ """
428
+ gh_repo = self._get_repo(owner, repo)
429
+ branches = []
430
+
431
+ for branch in gh_repo.get_branches()[:limit]:
432
+ branches.append(
433
+ {
434
+ "name": branch.name,
435
+ "sha": branch.commit.sha,
436
+ "protected": branch.protected,
437
+ }
438
+ )
439
+
440
+ return branches