quickcall-integrations 0.1.8__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.
@@ -3,6 +3,8 @@ Authentication tools for QuickCall MCP.
3
3
 
4
4
  Provides tools for users to connect, check status, and disconnect
5
5
  their QuickCall account from the CLI.
6
+
7
+ Also provides GitHub PAT authentication for users who can't install the GitHub App.
6
8
  """
7
9
 
8
10
  import os
@@ -11,11 +13,12 @@ import webbrowser
11
13
  from typing import Dict, Any
12
14
 
13
15
  import httpx
16
+ from github import Github, Auth, GithubException
14
17
  from fastmcp import FastMCP
18
+ from pydantic import Field
15
19
 
16
20
  from mcp_server.auth import (
17
21
  get_credential_store,
18
- is_authenticated,
19
22
  DeviceFlowAuth,
20
23
  )
21
24
 
@@ -100,46 +103,75 @@ def create_auth_tools(mcp: FastMCP):
100
103
  Check your QuickCall connection status.
101
104
 
102
105
  Shows:
103
- - Whether you're connected
106
+ - Whether you're connected to QuickCall
104
107
  - Your account info
105
- - GitHub connection status
108
+ - GitHub connection status (via App or PAT)
106
109
  - Slack connection status
107
110
 
108
111
  Returns:
109
112
  Current authentication and integration status
110
113
  """
111
114
  store = get_credential_store()
112
-
113
- if not store.is_authenticated():
114
- return {
115
- "connected": False,
116
- "message": "Not connected to QuickCall",
117
- "hint": "Use connect_quickcall to authenticate.",
118
- }
119
-
120
115
  status = store.get_status()
121
116
 
122
- return {
123
- "connected": True,
124
- "user": {
117
+ # Build result with both QuickCall and PAT status
118
+ result = {
119
+ "quickcall_connected": status.get("quickcall_authenticated", False),
120
+ "credentials_file": status.get("credentials_file"),
121
+ }
122
+
123
+ # QuickCall user info
124
+ if status.get("quickcall_authenticated"):
125
+ result["user"] = {
125
126
  "id": status.get("user_id"),
126
127
  "email": status.get("email"),
127
128
  "username": status.get("username"),
128
- },
129
- "authenticated_at": status.get("authenticated_at"),
130
- "integrations": {
131
- "github": {
132
- "connected": status.get("github", {}).get("connected", False),
133
- "username": status.get("github", {}).get("username"),
134
- },
135
- "slack": {
136
- "connected": status.get("slack", {}).get("connected", False),
137
- "team_name": status.get("slack", {}).get("team_name"),
138
- },
139
- },
140
- "credentials_file": status.get("credentials_file"),
129
+ }
130
+ result["authenticated_at"] = status.get("authenticated_at")
131
+
132
+ # GitHub status (can be via App or PAT)
133
+ github_status = status.get("github", {})
134
+ result["github"] = {
135
+ "connected": github_status.get("connected", False),
136
+ "mode": github_status.get("mode"), # "github_app" or "pat"
137
+ "username": github_status.get("username"),
138
+ }
139
+
140
+ # PAT-specific info if configured
141
+ github_pat = status.get("github_pat", {})
142
+ if github_pat.get("configured"):
143
+ result["github_pat"] = {
144
+ "configured": True,
145
+ "username": github_pat.get("username"),
146
+ "configured_at": github_pat.get("configured_at"),
147
+ }
148
+
149
+ # Slack status (requires QuickCall)
150
+ slack_status = status.get("slack", {})
151
+ result["slack"] = {
152
+ "connected": slack_status.get("connected", False),
153
+ "team_name": slack_status.get("team_name"),
141
154
  }
142
155
 
156
+ # Add helpful message based on status
157
+ if not status.get("quickcall_authenticated") and not github_pat.get(
158
+ "configured"
159
+ ):
160
+ result["message"] = "Not connected to QuickCall or GitHub"
161
+ result["hint"] = (
162
+ "Use connect_quickcall for full access, or connect_github_via_pat for GitHub only."
163
+ )
164
+ elif not status.get("quickcall_authenticated") and github_pat.get("configured"):
165
+ result["message"] = "GitHub connected via PAT (QuickCall not connected)"
166
+ result["hint"] = "Use connect_quickcall to also access Slack tools."
167
+ elif status.get("quickcall_authenticated"):
168
+ result["message"] = "Connected to QuickCall"
169
+
170
+ # Legacy compatibility
171
+ result["connected"] = status.get("quickcall_authenticated", False)
172
+
173
+ return result
174
+
143
175
  @mcp.tool(tags={"auth", "quickcall"})
144
176
  def disconnect_quickcall() -> Dict[str, Any]:
145
177
  """
@@ -493,3 +525,138 @@ def create_auth_tools(mcp: FastMCP):
493
525
  "status": "error",
494
526
  "message": f"Failed to reconnect Slack: {e}",
495
527
  }
528
+
529
+ # ========================================================================
530
+ # GitHub PAT Authentication (alternative to QuickCall GitHub App)
531
+ # ========================================================================
532
+
533
+ @mcp.tool(tags={"auth", "github"})
534
+ def connect_github_via_pat(
535
+ token: str = Field(
536
+ ...,
537
+ description="GitHub Personal Access Token (ghp_xxx or github_pat_xxx)",
538
+ ),
539
+ ) -> Dict[str, Any]:
540
+ """
541
+ Connect GitHub using a Personal Access Token (PAT).
542
+
543
+ Use this if your organization can't install the QuickCall GitHub App.
544
+ This is an alternative to the standard connect_github flow.
545
+
546
+ This command:
547
+ 1. Validates your PAT by calling GitHub API
548
+ 2. Auto-detects your GitHub username
549
+ 3. Stores the PAT securely in ~/.quickcall/credentials.json
550
+
551
+ After connecting, you can use GitHub tools like list_repos, list_prs, etc.
552
+
553
+ Create a PAT at: https://github.com/settings/tokens
554
+ Required scopes:
555
+ - repo (full access to private repos)
556
+ - OR public_repo (public repos only)
557
+
558
+ Note: PAT mode works independently of QuickCall. You don't need
559
+ to run connect_quickcall first. However, Slack tools still require
560
+ QuickCall authentication.
561
+ """
562
+ store = get_credential_store()
563
+
564
+ # Check if already configured via stored PAT
565
+ if store.has_github_pat():
566
+ pat_creds = store.get_github_pat_credentials()
567
+ return {
568
+ "status": "already_connected",
569
+ "message": f"GitHub PAT is already configured (username: {pat_creds.username})",
570
+ "configured_at": pat_creds.configured_at,
571
+ "hint": "Use disconnect_github_pat to remove it, then connect again with a new token.",
572
+ }
573
+
574
+ # Validate token format
575
+ if not token.startswith(("ghp_", "github_pat_")):
576
+ return {
577
+ "status": "error",
578
+ "message": "Invalid token format. GitHub PATs start with 'ghp_' or 'github_pat_'",
579
+ "hint": "Create a new token at https://github.com/settings/tokens",
580
+ }
581
+
582
+ # Validate token by calling GitHub API
583
+ try:
584
+ auth = Auth.Token(token)
585
+ gh = Github(auth=auth)
586
+ user = gh.get_user()
587
+ username = user.login
588
+ gh.close()
589
+ except GithubException as e:
590
+ if e.status == 401:
591
+ return {
592
+ "status": "error",
593
+ "message": "Invalid or expired token.",
594
+ "hint": "Check your token at https://github.com/settings/tokens",
595
+ }
596
+ return {
597
+ "status": "error",
598
+ "message": f"GitHub API error: {e.data.get('message', str(e))}",
599
+ }
600
+ except Exception as e:
601
+ logger.error(f"Failed to validate GitHub PAT: {e}")
602
+ return {
603
+ "status": "error",
604
+ "message": f"Failed to validate token: {e}",
605
+ }
606
+
607
+ # Store the PAT
608
+ try:
609
+ store.save_github_pat(token=token, username=username)
610
+ except Exception as e:
611
+ logger.error(f"Failed to save GitHub PAT: {e}")
612
+ return {
613
+ "status": "error",
614
+ "message": f"Failed to save token: {e}",
615
+ }
616
+
617
+ return {
618
+ "status": "success",
619
+ "message": f"Successfully connected GitHub as {username}!",
620
+ "username": username,
621
+ "mode": "pat",
622
+ "hint": "You can now use GitHub tools. Run check_github_connection to verify.",
623
+ }
624
+
625
+ @mcp.tool(tags={"auth", "github"})
626
+ def disconnect_github_pat() -> Dict[str, Any]:
627
+ """
628
+ Disconnect GitHub PAT authentication.
629
+
630
+ This removes only the stored PAT. If you also have QuickCall
631
+ connected, that connection remains intact.
632
+
633
+ After disconnecting:
634
+ - If you have QuickCall GitHub App connected, it will be used instead
635
+ - Otherwise, GitHub tools will be unavailable until you reconnect
636
+ """
637
+ store = get_credential_store()
638
+
639
+ if not store.has_github_pat():
640
+ return {
641
+ "status": "not_connected",
642
+ "message": "No GitHub PAT is configured.",
643
+ "hint": "Use connect_github_via_pat to set up PAT authentication.",
644
+ }
645
+
646
+ try:
647
+ pat_creds = store.get_github_pat_credentials()
648
+ username = pat_creds.username if pat_creds else "unknown"
649
+
650
+ store.clear_github_pat()
651
+
652
+ return {
653
+ "status": "disconnected",
654
+ "message": f"Disconnected GitHub PAT ({username})",
655
+ "hint": "Use connect_github_via_pat to reconnect, or connect_github to use the QuickCall GitHub App instead.",
656
+ }
657
+ except Exception as e:
658
+ logger.error(f"Failed to disconnect GitHub PAT: {e}")
659
+ return {
660
+ "status": "error",
661
+ "message": f"Failed to disconnect: {e}",
662
+ }
@@ -4,6 +4,7 @@ Git Tools - Simple tools for viewing repository changes.
4
4
 
5
5
  from typing import Optional, List
6
6
  import subprocess
7
+ import re
7
8
 
8
9
  from fastmcp import FastMCP
9
10
  from fastmcp.exceptions import ToolError
@@ -230,3 +231,176 @@ def create_git_tools(mcp: FastMCP) -> None:
230
231
  raise
231
232
  except Exception as e:
232
233
  raise ToolError(f"Failed to get updates: {str(e)}")
234
+
235
+ @mcp.tool(tags={"git", "appraisal"})
236
+ def get_local_contributions(
237
+ path: str = Field(
238
+ ...,
239
+ description="Path to git repository.",
240
+ ),
241
+ days: int = Field(
242
+ default=180,
243
+ description="Number of days to look back (default: 180 for 6 months)",
244
+ ),
245
+ author: Optional[str] = Field(
246
+ default=None,
247
+ description="Filter by author name/email. If not specified, uses git config user.email",
248
+ ),
249
+ ) -> dict:
250
+ """
251
+ Get contributions from local git history for appraisals.
252
+
253
+ Returns commits with:
254
+ - commit message (for Claude to categorize: feat, fix, chore, etc.)
255
+ - PR number (extracted from merge commits if available)
256
+ - stats (additions, deletions)
257
+ - date range
258
+
259
+ Claude should analyze commit messages to categorize contributions.
260
+ This tool does NOT do any categorization - it returns raw data.
261
+
262
+ Use this for appraisals when:
263
+ - GitHub App is not connected
264
+ - You want local repo contributions only
265
+ - Testing appraisal features on cloned repos
266
+ """
267
+ try:
268
+ repo_info = _get_repo_info(path)
269
+ repo_name = (
270
+ f"{repo_info['owner']}/{repo_info['repo']}"
271
+ if repo_info["owner"]
272
+ else repo_info["root"]
273
+ )
274
+
275
+ # Get author from git config if not specified
276
+ if not author:
277
+ try:
278
+ author = _run_git(["config", "user.email"], path)
279
+ except ToolError:
280
+ pass
281
+
282
+ since_date = f"{days} days ago"
283
+
284
+ # Get all commits by author
285
+ log_format = "--pretty=format:%H|%an|%ae|%ad|%s|%b---COMMIT_END---"
286
+ log_args = [
287
+ "log",
288
+ log_format,
289
+ "--date=iso",
290
+ f"--since={since_date}",
291
+ ]
292
+ if author:
293
+ log_args.extend(["--author", author])
294
+
295
+ log_output = _run_git(log_args, path)
296
+
297
+ commits = []
298
+ merge_commits = []
299
+
300
+ # Parse commits
301
+ for commit_block in log_output.split("---COMMIT_END---"):
302
+ if not commit_block.strip():
303
+ continue
304
+
305
+ lines = commit_block.strip().split("|", 5)
306
+ if len(lines) < 5:
307
+ continue
308
+
309
+ sha = lines[0]
310
+ author_name = lines[1]
311
+ author_email = lines[2]
312
+ date = lines[3]
313
+ subject = lines[4]
314
+ body = lines[5] if len(lines) > 5 else ""
315
+
316
+ # Extract PR number from merge commit message
317
+ pr_number = None
318
+ merge_match = re.search(r"Merge pull request #(\d+)", subject)
319
+ if merge_match:
320
+ pr_number = int(merge_match.group(1))
321
+ else:
322
+ # Also check for GitHub's squash merge format
323
+ squash_match = re.search(r"\(#(\d+)\)$", subject)
324
+ if squash_match:
325
+ pr_number = int(squash_match.group(1))
326
+
327
+ commit_data = {
328
+ "sha": sha[:7],
329
+ "full_sha": sha,
330
+ "author": author_name,
331
+ "email": author_email,
332
+ "date": date,
333
+ "message": subject,
334
+ "body": body.strip()[:500] if body else "",
335
+ "pr_number": pr_number,
336
+ }
337
+
338
+ commits.append(commit_data)
339
+
340
+ if pr_number:
341
+ merge_commits.append(commit_data)
342
+
343
+ # Get stats for commits if we have any
344
+ stats = {"total_additions": 0, "total_deletions": 0, "files_changed": set()}
345
+
346
+ if commits:
347
+ # Get numstat for all commits by this author
348
+ numstat_args = [
349
+ "log",
350
+ "--numstat",
351
+ "--pretty=format:",
352
+ f"--since={since_date}",
353
+ ]
354
+ if author:
355
+ numstat_args.extend(["--author", author])
356
+
357
+ try:
358
+ numstat_output = _run_git(numstat_args, path)
359
+ for line in numstat_output.split("\n"):
360
+ if not line.strip():
361
+ continue
362
+ parts = line.split("\t")
363
+ if len(parts) >= 3:
364
+ adds = int(parts[0]) if parts[0] != "-" else 0
365
+ dels = int(parts[1]) if parts[1] != "-" else 0
366
+ stats["total_additions"] += adds
367
+ stats["total_deletions"] += dels
368
+ stats["files_changed"].add(parts[2])
369
+ except ToolError:
370
+ pass
371
+
372
+ # Calculate date range
373
+ if commits:
374
+ dates = [c["date"] for c in commits]
375
+ earliest = min(dates)
376
+ latest = max(dates)
377
+ else:
378
+ earliest = latest = None
379
+
380
+ return {
381
+ "repository": repo_name,
382
+ "branch": repo_info["branch"],
383
+ "author": author,
384
+ "period_days": days,
385
+ "date_range": {
386
+ "earliest": earliest,
387
+ "latest": latest,
388
+ },
389
+ "summary": {
390
+ "total_commits": len(commits),
391
+ "merge_commits": len(merge_commits),
392
+ "unique_prs": len(
393
+ set(c["pr_number"] for c in merge_commits if c["pr_number"])
394
+ ),
395
+ "total_additions": stats["total_additions"],
396
+ "total_deletions": stats["total_deletions"],
397
+ "files_touched": len(stats["files_changed"]),
398
+ },
399
+ "commits": commits,
400
+ "pr_commits": merge_commits,
401
+ }
402
+
403
+ except ToolError:
404
+ raise
405
+ except Exception as e:
406
+ raise ToolError(f"Failed to get contributions: {str(e)}")