quickcall-integrations 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (39) hide show
  1. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/.gitignore +1 -0
  2. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/PKG-INFO +1 -1
  3. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/api_clients/github_client.py +162 -31
  4. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/tools/github_tools.py +210 -6
  5. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/.claude-plugin/plugin.json +1 -1
  6. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/pyproject.toml +3 -1
  7. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/uv.lock +131 -1
  8. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/.claude-plugin/marketplace.json +0 -0
  9. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/.github/workflows/publish-pypi.yml +0 -0
  10. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/.pre-commit-config.yaml +0 -0
  11. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/Dockerfile +0 -0
  12. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/README.md +0 -0
  13. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/assets/logo.png +0 -0
  14. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/__init__.py +0 -0
  15. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/api_clients/__init__.py +0 -0
  16. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/api_clients/slack_client.py +0 -0
  17. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/auth/__init__.py +0 -0
  18. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/auth/credentials.py +0 -0
  19. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/auth/device_flow.py +0 -0
  20. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/resources/__init__.py +0 -0
  21. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/resources/slack_resources.py +0 -0
  22. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/server.py +0 -0
  23. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/tools/__init__.py +0 -0
  24. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/tools/auth_tools.py +0 -0
  25. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/tools/git_tools.py +0 -0
  26. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/tools/slack_tools.py +0 -0
  27. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/mcp_server/tools/utility_tools.py +0 -0
  28. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/commands/appraisal.md +0 -0
  29. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/commands/connect-github-pat.md +0 -0
  30. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/commands/connect.md +0 -0
  31. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/commands/slack-summary.md +0 -0
  32. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/commands/status.md +0 -0
  33. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/plugins/quickcall/commands/updates.md +0 -0
  34. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/requirements.txt +0 -0
  35. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/tests/README.md +0 -0
  36. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/tests/appraisal/__init__.py +0 -0
  37. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/tests/appraisal/setup_test_data.py +0 -0
  38. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/tests/test_integrations.py +0 -0
  39. {quickcall_integrations-0.2.0 → quickcall_integrations-0.3.0}/tests/test_tools.py +0 -0
@@ -107,3 +107,4 @@ dmypy.json
107
107
  # OS
108
108
  .DS_Store
109
109
  Thumbs.db
110
+ .githooks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickcall-integrations
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: MCP server with developer integrations for Claude Code and Cursor
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: fastmcp>=2.13.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
 
@@ -298,7 +321,8 @@ class GitHubClient:
298
321
  repo: Optional[str] = None,
299
322
  state: str = "open",
300
323
  limit: int = 20,
301
- ) -> List[PullRequest]:
324
+ detail_level: str = "summary",
325
+ ) -> List[PullRequest] | List[PullRequestSummary]:
302
326
  """
303
327
  List pull requests.
304
328
 
@@ -307,9 +331,11 @@ class GitHubClient:
307
331
  repo: Repository name
308
332
  state: PR state: 'open', 'closed', or 'all'
309
333
  limit: Maximum PRs to return
334
+ detail_level: 'summary' for minimal fields (~200 bytes/PR),
335
+ 'full' for all fields (~2KB/PR)
310
336
 
311
337
  Returns:
312
- List of pull requests
338
+ List of pull requests (summary or full based on detail_level)
313
339
  """
314
340
  gh_repo = self._get_repo(owner, repo)
315
341
  prs = []
@@ -319,7 +345,10 @@ class GitHubClient:
319
345
  for i, pr in enumerate(pulls):
320
346
  if i >= limit:
321
347
  break
322
- prs.append(self._convert_pr(pr))
348
+ if detail_level == "full":
349
+ prs.append(self._convert_pr(pr))
350
+ else:
351
+ prs.append(self._convert_pr_summary(pr))
323
352
  except IndexError:
324
353
  # Empty repo or no PRs - return empty list
325
354
  pass
@@ -353,7 +382,7 @@ class GitHubClient:
353
382
  raise
354
383
 
355
384
  def _convert_pr(self, pr) -> PullRequest:
356
- """Convert PyGithub PullRequest to Pydantic model."""
385
+ """Convert PyGithub PullRequest to Pydantic model (full details)."""
357
386
  return PullRequest(
358
387
  number=pr.number,
359
388
  title=pr.title,
@@ -376,6 +405,18 @@ class GitHubClient:
376
405
  reviewers=[r.login for r in pr.requested_reviewers],
377
406
  )
378
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
+
379
420
  # ========================================================================
380
421
  # Commit Operations
381
422
  # ========================================================================
@@ -388,7 +429,8 @@ class GitHubClient:
388
429
  author: Optional[str] = None,
389
430
  since: Optional[str] = None,
390
431
  limit: int = 20,
391
- ) -> List[Commit]:
432
+ detail_level: str = "summary",
433
+ ) -> List[Commit] | List[CommitSummary]:
392
434
  """
393
435
  List commits.
394
436
 
@@ -399,9 +441,10 @@ class GitHubClient:
399
441
  author: Filter by author username
400
442
  since: ISO datetime - only commits after this date
401
443
  limit: Maximum commits to return
444
+ detail_level: 'summary' for minimal fields, 'full' for all fields
402
445
 
403
446
  Returns:
404
- List of commits
447
+ List of commits (summary or full based on detail_level)
405
448
  """
406
449
  gh_repo = self._get_repo(owner, repo)
407
450
 
@@ -427,15 +470,28 @@ class GitHubClient:
427
470
  if author and author.lower() != commit_author.lower():
428
471
  continue
429
472
 
430
- commits.append(
431
- Commit(
432
- sha=commit.sha,
433
- message=commit.commit.message,
434
- author=commit_author,
435
- date=commit.commit.author.date,
436
- 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
+ )
437
494
  )
438
- )
439
495
 
440
496
  return commits
441
497
 
@@ -533,6 +589,7 @@ class GitHubClient:
533
589
  org: Optional[str] = None,
534
590
  repo: Optional[str] = None,
535
591
  limit: int = 100,
592
+ detail_level: str = "summary",
536
593
  ) -> List[Dict[str, Any]]:
537
594
  """
538
595
  Search for merged pull requests using GitHub Search API.
@@ -545,10 +602,11 @@ class GitHubClient:
545
602
  org: GitHub org to search within
546
603
  repo: Specific repo in "owner/repo" format (overrides org if specified)
547
604
  limit: Maximum PRs to return (max 100 per page)
605
+ detail_level: 'summary' for minimal fields, 'full' for all fields
548
606
 
549
607
  Returns:
550
- List of merged PR dicts with: number, title, body, merged_at,
551
- labels, repo, owner, html_url
608
+ List of merged PR dicts. Summary includes: number, title, merged_at,
609
+ repo, owner, html_url, author. Full adds: body, labels.
552
610
  """
553
611
  # Build search query
554
612
  query_parts = ["is:pr", "is:merged"]
@@ -596,19 +654,35 @@ class GitHubClient:
596
654
  repo_owner = repo_url_parts[-2] if len(repo_url_parts) >= 2 else ""
597
655
  repo_name = repo_url_parts[-1] if len(repo_url_parts) >= 1 else ""
598
656
 
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
- )
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
+ )
612
686
 
613
687
  return prs
614
688
 
@@ -618,3 +692,60 @@ class GitHubClient:
618
692
  except Exception as e:
619
693
  logger.error(f"Failed to search PRs: {e}")
620
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
@@ -11,7 +11,7 @@ PAT fallback is useful for:
11
11
  - Testing and development
12
12
  """
13
13
 
14
- from typing import Optional, Tuple
14
+ from typing import List, Optional, Tuple
15
15
  import logging
16
16
 
17
17
  from fastmcp import FastMCP
@@ -154,19 +154,34 @@ def create_github_tools(mcp: FastMCP) -> None:
154
154
  default=20,
155
155
  description="Maximum number of PRs to return (default: 20)",
156
156
  ),
157
+ detail_level: str = Field(
158
+ default="summary",
159
+ description="'summary' for minimal fields (~200 bytes/PR: number, title, state, author, merged_at, html_url), "
160
+ "'full' for all fields (~2KB/PR). Use 'summary' for large result sets, 'full' for detailed analysis.",
161
+ ),
157
162
  ) -> dict:
158
163
  """
159
164
  List pull requests for a GitHub repository.
160
165
 
161
166
  Returns PRs sorted by last updated.
162
167
  Requires QuickCall authentication with GitHub connected.
168
+
169
+ Use detail_level='summary' (default) to avoid context overflow with large result sets.
170
+ Use get_pr(number) to get full details for specific PRs when needed.
163
171
  """
164
172
  try:
165
173
  client = _get_client()
166
- prs = client.list_prs(owner=owner, repo=repo, state=state, limit=limit)
174
+ prs = client.list_prs(
175
+ owner=owner,
176
+ repo=repo,
177
+ state=state,
178
+ limit=limit,
179
+ detail_level=detail_level,
180
+ )
167
181
 
168
182
  return {
169
183
  "count": len(prs),
184
+ "detail_level": detail_level,
170
185
  "prs": [pr.model_dump() for pr in prs],
171
186
  }
172
187
  except ToolError:
@@ -241,12 +256,20 @@ def create_github_tools(mcp: FastMCP) -> None:
241
256
  default=20,
242
257
  description="Maximum number of commits to return (default: 20)",
243
258
  ),
259
+ detail_level: str = Field(
260
+ default="summary",
261
+ description="'summary' for minimal fields (short sha, message title, author, date, url), "
262
+ "'full' for all fields including full commit message. Use 'summary' for large result sets.",
263
+ ),
244
264
  ) -> dict:
245
265
  """
246
266
  List commits for a GitHub repository.
247
267
 
248
268
  Returns commits sorted by date (newest first).
249
269
  Requires QuickCall authentication with GitHub connected.
270
+
271
+ Use detail_level='summary' (default) to avoid context overflow with large result sets.
272
+ Use get_commit(sha) to get full details for specific commits when needed.
250
273
  """
251
274
  try:
252
275
  client = _get_client()
@@ -257,10 +280,12 @@ def create_github_tools(mcp: FastMCP) -> None:
257
280
  author=author,
258
281
  since=since,
259
282
  limit=limit,
283
+ detail_level=detail_level,
260
284
  )
261
285
 
262
286
  return {
263
287
  "count": len(commits),
288
+ "detail_level": detail_level,
264
289
  "commits": [commit.model_dump() for commit in commits],
265
290
  }
266
291
  except ToolError:
@@ -370,6 +395,11 @@ def create_github_tools(mcp: FastMCP) -> None:
370
395
  default=100,
371
396
  description="Maximum PRs to return (default: 100)",
372
397
  ),
398
+ detail_level: str = Field(
399
+ default="summary",
400
+ description="'summary' for minimal fields (number, title, merged_at, repo, owner, html_url, author), "
401
+ "'full' adds body and labels. Use 'summary' for large result sets.",
402
+ ),
373
403
  ) -> dict:
374
404
  """
375
405
  Search for merged pull requests by author within a time period.
@@ -390,8 +420,8 @@ def create_github_tools(mcp: FastMCP) -> None:
390
420
 
391
421
  3. SUMMARIZE for appraisal with accomplishments grouped by category
392
422
 
393
- Returns: number, title, body, merged_at, labels, repo, owner, html_url, author.
394
- For full stats (additions, deletions, files), call get_pr on specific PRs.
423
+ Use detail_level='summary' (default) to avoid context overflow with large result sets.
424
+ Use get_pr(number) to get full details for specific PRs when needed.
395
425
 
396
426
  Requires QuickCall authentication with GitHub connected.
397
427
  """
@@ -399,9 +429,11 @@ def create_github_tools(mcp: FastMCP) -> None:
399
429
  client = _get_client()
400
430
 
401
431
  # Calculate since_date from days
402
- from datetime import datetime, timedelta
432
+ from datetime import datetime, timedelta, timezone
403
433
 
404
- since_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
434
+ since_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
435
+ "%Y-%m-%d"
436
+ )
405
437
 
406
438
  # Use authenticated user if author not specified
407
439
  if not author:
@@ -415,10 +447,12 @@ def create_github_tools(mcp: FastMCP) -> None:
415
447
  org=org,
416
448
  repo=repo,
417
449
  limit=limit,
450
+ detail_level=detail_level,
418
451
  )
419
452
 
420
453
  return {
421
454
  "count": len(prs),
455
+ "detail_level": detail_level,
422
456
  "period": f"Last {days} days",
423
457
  "author": author,
424
458
  "org": org,
@@ -430,6 +464,176 @@ def create_github_tools(mcp: FastMCP) -> None:
430
464
  except Exception as e:
431
465
  raise ToolError(f"Failed to search merged PRs: {str(e)}")
432
466
 
467
+ @mcp.tool(tags={"github", "prs", "appraisal"})
468
+ def prepare_appraisal_data(
469
+ author: Optional[str] = Field(
470
+ default=None,
471
+ description="GitHub username. Defaults to authenticated user.",
472
+ ),
473
+ days: int = Field(
474
+ default=180,
475
+ description="Number of days to look back (default: 180 for ~6 months)",
476
+ ),
477
+ org: Optional[str] = Field(
478
+ default=None,
479
+ description="GitHub org to search within.",
480
+ ),
481
+ repo: Optional[str] = Field(
482
+ default=None,
483
+ description="Specific repo in 'owner/repo' format.",
484
+ ),
485
+ ) -> dict:
486
+ """
487
+ Prepare appraisal data by fetching ALL merged PRs with full details.
488
+
489
+ This tool:
490
+ 1. Searches for all merged PRs by the author
491
+ 2. Fetches FULL details (additions, deletions, files) for each PR IN PARALLEL
492
+ 3. Dumps everything to a JSON file for later queries
493
+ 4. Returns the file path + list of PR titles for Claude to review
494
+
495
+ USE THIS for appraisals instead of search_merged_prs + multiple get_pr calls.
496
+ After calling this, use get_appraisal_pr_details to get full info for specific PRs.
497
+ """
498
+ import json
499
+ import tempfile
500
+ from datetime import datetime, timedelta, timezone
501
+
502
+ try:
503
+ client = _get_client()
504
+
505
+ since_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
506
+ "%Y-%m-%d"
507
+ )
508
+
509
+ # Use authenticated user if author not specified
510
+ if not author:
511
+ creds = get_credential_store().get_api_credentials()
512
+ if creds and creds.github_username:
513
+ author = creds.github_username
514
+
515
+ # Step 1: Get list of merged PRs
516
+ pr_list = client.search_merged_prs(
517
+ author=author,
518
+ since_date=since_date,
519
+ org=org,
520
+ repo=repo,
521
+ limit=100,
522
+ detail_level="full", # Get body/labels from search
523
+ )
524
+
525
+ if not pr_list:
526
+ return {
527
+ "count": 0,
528
+ "message": "No merged PRs found for the specified criteria",
529
+ "author": author,
530
+ "period": f"Last {days} days",
531
+ }
532
+
533
+ # Step 2: Prepare refs for parallel fetch
534
+ pr_refs = [
535
+ {"owner": pr["owner"], "repo": pr["repo"], "number": pr["number"]}
536
+ for pr in pr_list
537
+ ]
538
+
539
+ # Step 3: Fetch full details in parallel
540
+ full_prs = client.fetch_prs_parallel(pr_refs, max_workers=10)
541
+
542
+ # Step 4: Merge search data with full PR data
543
+ # (search has body/labels, full PR has additions/deletions/files)
544
+ pr_lookup = {(pr["owner"], pr["repo"], pr["number"]): pr for pr in pr_list}
545
+ for pr in full_prs:
546
+ key = (pr["owner"], pr["repo"], pr["number"])
547
+ if key in pr_lookup:
548
+ # Add labels from search (not in PyGithub response for some reason)
549
+ search_pr = pr_lookup[key]
550
+ if "labels" in search_pr:
551
+ pr["labels"] = search_pr["labels"]
552
+
553
+ # Step 5: Dump to file
554
+ dump_data = {
555
+ "author": author,
556
+ "period": f"Last {days} days",
557
+ "org": org,
558
+ "repo": repo,
559
+ "fetched_at": datetime.now(timezone.utc).isoformat(),
560
+ "count": len(full_prs),
561
+ "prs": full_prs,
562
+ }
563
+
564
+ # Create temp file that persists
565
+ fd, file_path = tempfile.mkstemp(suffix=".json", prefix="appraisal_")
566
+ with open(file_path, "w") as f:
567
+ json.dump(dump_data, f, indent=2, default=str)
568
+
569
+ # Step 6: Return file path + just titles for Claude to scan
570
+ pr_titles = [
571
+ {
572
+ "number": pr["number"],
573
+ "title": pr["title"],
574
+ "repo": f"{pr['owner']}/{pr['repo']}",
575
+ }
576
+ for pr in full_prs
577
+ ]
578
+
579
+ return {
580
+ "file_path": file_path,
581
+ "count": len(full_prs),
582
+ "author": author,
583
+ "period": f"Last {days} days",
584
+ "pr_titles": pr_titles,
585
+ "next_step": "Review titles above, then call "
586
+ "get_appraisal_pr_details(file_path, pr_numbers) for full details on selected PRs.",
587
+ }
588
+
589
+ except ToolError:
590
+ raise
591
+ except Exception as e:
592
+ raise ToolError(f"Failed to prepare appraisal data: {str(e)}")
593
+
594
+ @mcp.tool(tags={"github", "prs", "appraisal"})
595
+ def get_appraisal_pr_details(
596
+ file_path: str = Field(
597
+ ...,
598
+ description="Path to the appraisal data file from prepare_appraisal_data",
599
+ ),
600
+ pr_numbers: List[int] = Field(
601
+ ..., description="List of PR numbers to get full details for"
602
+ ),
603
+ ) -> dict:
604
+ """
605
+ Get full PR details from the appraisal data dump.
606
+
607
+ This reads from the local file created by prepare_appraisal_data.
608
+ NO API CALLS are made - all data comes from the cached dump.
609
+
610
+ Use this after prepare_appraisal_data to get full details for specific PRs
611
+ that Claude has identified as important for the appraisal.
612
+ """
613
+ import json
614
+
615
+ try:
616
+ with open(file_path) as f:
617
+ data = json.load(f)
618
+
619
+ pr_numbers_set = set(pr_numbers)
620
+ selected_prs = [
621
+ pr for pr in data.get("prs", []) if pr["number"] in pr_numbers_set
622
+ ]
623
+
624
+ return {
625
+ "count": len(selected_prs),
626
+ "requested": len(pr_numbers),
627
+ "prs": selected_prs,
628
+ }
629
+
630
+ except FileNotFoundError:
631
+ raise ToolError(f"Appraisal data file not found: {file_path}")
632
+ except json.JSONDecodeError:
633
+ raise ToolError(f"Invalid JSON in appraisal data file: {file_path}")
634
+ except Exception as e:
635
+ raise ToolError(f"Failed to read appraisal data: {str(e)}")
636
+
433
637
  @mcp.tool(tags={"github", "status"})
434
638
  def check_github_connection() -> dict:
435
639
  """
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "quickcall",
3
3
  "description": "Integrate quickcall into dev workflows - eliminate interruptions for developers. Ask about your work, get instant answers. No more context switching.",
4
- "version": "0.5.2",
4
+ "version": "0.6.0",
5
5
  "author": {
6
6
  "name": "Sagar Sarkale"
7
7
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quickcall-integrations"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "MCP server with developer integrations for Claude Code and Cursor"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -27,4 +27,6 @@ dev = [
27
27
  "black>=25.12.0",
28
28
  "mcp>=1.9.0",
29
29
  "rich>=13.0.0",
30
+ "ruff>=0.14.11",
31
+ "twine>=6.2.0",
30
32
  ]
@@ -571,6 +571,18 @@ wheels = [
571
571
  { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
572
572
  ]
573
573
 
574
+ [[package]]
575
+ name = "id"
576
+ version = "1.5.0"
577
+ source = { registry = "https://pypi.org/simple" }
578
+ dependencies = [
579
+ { name = "requests" },
580
+ ]
581
+ sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" }
582
+ wheels = [
583
+ { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" },
584
+ ]
585
+
574
586
  [[package]]
575
587
  name = "idna"
576
588
  version = "3.11"
@@ -835,6 +847,39 @@ wheels = [
835
847
  { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
836
848
  ]
837
849
 
850
+ [[package]]
851
+ name = "nh3"
852
+ version = "0.3.2"
853
+ source = { registry = "https://pypi.org/simple" }
854
+ sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" }
855
+ wheels = [
856
+ { url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839, upload-time = "2025-10-30T11:17:09.956Z" },
857
+ { url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183, upload-time = "2025-10-30T11:17:11.99Z" },
858
+ { url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127, upload-time = "2025-10-30T11:17:13.192Z" },
859
+ { url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131, upload-time = "2025-10-30T11:17:14.677Z" },
860
+ { url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783, upload-time = "2025-10-30T11:17:15.861Z" },
861
+ { url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732, upload-time = "2025-10-30T11:17:17.155Z" },
862
+ { url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997, upload-time = "2025-10-30T11:17:18.77Z" },
863
+ { url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364, upload-time = "2025-10-30T11:17:20.286Z" },
864
+ { url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982, upload-time = "2025-10-30T11:17:21.384Z" },
865
+ { url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126, upload-time = "2025-10-30T11:17:22.755Z" },
866
+ { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" },
867
+ { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" },
868
+ { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" },
869
+ { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" },
870
+ { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" },
871
+ { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" },
872
+ { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" },
873
+ { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" },
874
+ { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" },
875
+ { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
876
+ { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
877
+ { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
878
+ { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
879
+ { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
880
+ { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
881
+ ]
882
+
838
883
  [[package]]
839
884
  name = "openapi-pydantic"
840
885
  version = "0.5.1"
@@ -1414,7 +1459,7 @@ wheels = [
1414
1459
 
1415
1460
  [[package]]
1416
1461
  name = "quickcall-integrations"
1417
- version = "0.1.9"
1462
+ version = "0.3.0"
1418
1463
  source = { editable = "." }
1419
1464
  dependencies = [
1420
1465
  { name = "fastmcp" },
@@ -1429,6 +1474,8 @@ dev = [
1429
1474
  { name = "black" },
1430
1475
  { name = "mcp" },
1431
1476
  { name = "rich" },
1477
+ { name = "ruff" },
1478
+ { name = "twine" },
1432
1479
  ]
1433
1480
 
1434
1481
  [package.metadata]
@@ -1445,6 +1492,8 @@ dev = [
1445
1492
  { name = "black", specifier = ">=25.12.0" },
1446
1493
  { name = "mcp", specifier = ">=1.9.0" },
1447
1494
  { name = "rich", specifier = ">=13.0.0" },
1495
+ { name = "ruff", specifier = ">=0.14.11" },
1496
+ { name = "twine", specifier = ">=6.2.0" },
1448
1497
  ]
1449
1498
 
1450
1499
  [[package]]
@@ -1537,6 +1586,20 @@ wheels = [
1537
1586
  { url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" },
1538
1587
  ]
1539
1588
 
1589
+ [[package]]
1590
+ name = "readme-renderer"
1591
+ version = "44.0"
1592
+ source = { registry = "https://pypi.org/simple" }
1593
+ dependencies = [
1594
+ { name = "docutils" },
1595
+ { name = "nh3" },
1596
+ { name = "pygments" },
1597
+ ]
1598
+ sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" }
1599
+ wheels = [
1600
+ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" },
1601
+ ]
1602
+
1540
1603
  [[package]]
1541
1604
  name = "redis"
1542
1605
  version = "7.1.0"
@@ -1578,6 +1641,27 @@ wheels = [
1578
1641
  { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
1579
1642
  ]
1580
1643
 
1644
+ [[package]]
1645
+ name = "requests-toolbelt"
1646
+ version = "1.0.0"
1647
+ source = { registry = "https://pypi.org/simple" }
1648
+ dependencies = [
1649
+ { name = "requests" },
1650
+ ]
1651
+ sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
1652
+ wheels = [
1653
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
1654
+ ]
1655
+
1656
+ [[package]]
1657
+ name = "rfc3986"
1658
+ version = "2.0.0"
1659
+ source = { registry = "https://pypi.org/simple" }
1660
+ sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" }
1661
+ wheels = [
1662
+ { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" },
1663
+ ]
1664
+
1581
1665
  [[package]]
1582
1666
  name = "rich"
1583
1667
  version = "14.2.0"
@@ -1726,6 +1810,32 @@ wheels = [
1726
1810
  { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
1727
1811
  ]
1728
1812
 
1813
+ [[package]]
1814
+ name = "ruff"
1815
+ version = "0.14.11"
1816
+ source = { registry = "https://pypi.org/simple" }
1817
+ sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" }
1818
+ wheels = [
1819
+ { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" },
1820
+ { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" },
1821
+ { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" },
1822
+ { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" },
1823
+ { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" },
1824
+ { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" },
1825
+ { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" },
1826
+ { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" },
1827
+ { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" },
1828
+ { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" },
1829
+ { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" },
1830
+ { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" },
1831
+ { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
1832
+ { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
1833
+ { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
1834
+ { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
1835
+ { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
1836
+ { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
1837
+ ]
1838
+
1729
1839
  [[package]]
1730
1840
  name = "secretstorage"
1731
1841
  version = "3.5.0"
@@ -1832,6 +1942,26 @@ wheels = [
1832
1942
  { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
1833
1943
  ]
1834
1944
 
1945
+ [[package]]
1946
+ name = "twine"
1947
+ version = "6.2.0"
1948
+ source = { registry = "https://pypi.org/simple" }
1949
+ dependencies = [
1950
+ { name = "id" },
1951
+ { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
1952
+ { name = "packaging" },
1953
+ { name = "readme-renderer" },
1954
+ { name = "requests" },
1955
+ { name = "requests-toolbelt" },
1956
+ { name = "rfc3986" },
1957
+ { name = "rich" },
1958
+ { name = "urllib3" },
1959
+ ]
1960
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" }
1961
+ wheels = [
1962
+ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" },
1963
+ ]
1964
+
1835
1965
  [[package]]
1836
1966
  name = "typer"
1837
1967
  version = "0.21.0"