quickcall-integrations 0.3.9__py3-none-any.whl → 0.4.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.
@@ -1155,3 +1155,906 @@ class GitHubClient:
1155
1155
  logger.warning(f"Failed to fetch {len(errors)} PRs: {errors[:5]}...")
1156
1156
 
1157
1157
  return results
1158
+
1159
+ # ========================================================================
1160
+ # Project Operations (GitHub Projects V2 via GraphQL)
1161
+ # ========================================================================
1162
+
1163
+ def _graphql_request(self, query: str, variables: Optional[Dict] = None) -> Dict:
1164
+ """
1165
+ Execute a GraphQL request against GitHub's API.
1166
+
1167
+ Args:
1168
+ query: GraphQL query or mutation string
1169
+ variables: Optional variables for the query
1170
+
1171
+ Returns:
1172
+ Response data dict
1173
+
1174
+ Raises:
1175
+ GithubException: If the request fails
1176
+ """
1177
+ try:
1178
+ with httpx.Client() as client:
1179
+ response = client.post(
1180
+ "https://api.github.com/graphql",
1181
+ headers={
1182
+ "Authorization": f"Bearer {self.token}",
1183
+ "Accept": "application/vnd.github+json",
1184
+ },
1185
+ json={"query": query, "variables": variables or {}},
1186
+ timeout=30.0,
1187
+ )
1188
+ response.raise_for_status()
1189
+ data = response.json()
1190
+
1191
+ if "errors" in data:
1192
+ error_messages = [e.get("message", str(e)) for e in data["errors"]]
1193
+ raise GithubException(
1194
+ 400, {"message": "; ".join(error_messages)}, "GraphQL Error"
1195
+ )
1196
+
1197
+ return data.get("data", {})
1198
+ except httpx.HTTPStatusError as e:
1199
+ logger.error(f"GraphQL request failed: HTTP {e.response.status_code}")
1200
+ raise GithubException(e.response.status_code, e.response.json())
1201
+ except GithubException:
1202
+ raise
1203
+ except Exception as e:
1204
+ logger.error(f"GraphQL request failed: {e}")
1205
+ raise GithubException(500, {"message": str(e)})
1206
+
1207
+ def list_projects(
1208
+ self,
1209
+ owner: Optional[str] = None,
1210
+ is_org: bool = True,
1211
+ limit: int = 20,
1212
+ ) -> List[Dict[str, Any]]:
1213
+ """
1214
+ List GitHub Projects V2 for an organization or user.
1215
+
1216
+ Args:
1217
+ owner: Organization or user name. Uses default_owner if not specified.
1218
+ is_org: If True, treat owner as organization. If False, treat as user.
1219
+ limit: Maximum projects to return (default: 20)
1220
+
1221
+ Returns:
1222
+ List of project dicts with id, number, title, url, closed
1223
+ """
1224
+ owner = owner or self.default_owner
1225
+ if not owner:
1226
+ raise ValueError("Owner must be specified for listing projects")
1227
+
1228
+ if is_org:
1229
+ query = """
1230
+ query($owner: String!, $limit: Int!) {
1231
+ organization(login: $owner) {
1232
+ projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
1233
+ nodes {
1234
+ id
1235
+ number
1236
+ title
1237
+ url
1238
+ closed
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+ """
1244
+ data = self._graphql_request(query, {"owner": owner, "limit": limit})
1245
+ org = data.get("organization")
1246
+ if not org:
1247
+ # Try as user if org lookup fails
1248
+ return self.list_projects(owner=owner, is_org=False, limit=limit)
1249
+ nodes = org.get("projectsV2", {}).get("nodes", [])
1250
+ else:
1251
+ query = """
1252
+ query($owner: String!, $limit: Int!) {
1253
+ user(login: $owner) {
1254
+ projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
1255
+ nodes {
1256
+ id
1257
+ number
1258
+ title
1259
+ url
1260
+ closed
1261
+ }
1262
+ }
1263
+ }
1264
+ }
1265
+ """
1266
+ data = self._graphql_request(query, {"owner": owner, "limit": limit})
1267
+ user = data.get("user")
1268
+ if not user:
1269
+ return []
1270
+ nodes = user.get("projectsV2", {}).get("nodes", [])
1271
+
1272
+ return [
1273
+ {
1274
+ "id": node["id"],
1275
+ "number": node["number"],
1276
+ "title": node["title"],
1277
+ "url": node["url"],
1278
+ "closed": node["closed"],
1279
+ }
1280
+ for node in nodes
1281
+ if node # Filter out None nodes
1282
+ ]
1283
+
1284
+ def get_project_id(
1285
+ self,
1286
+ project: str,
1287
+ owner: Optional[str] = None,
1288
+ is_org: bool = True,
1289
+ ) -> Optional[str]:
1290
+ """
1291
+ Get the node ID of a project by number or title.
1292
+
1293
+ Args:
1294
+ project: Project number (as string) or title
1295
+ owner: Organization or user name
1296
+ is_org: If True, treat owner as organization
1297
+
1298
+ Returns:
1299
+ Project node ID or None if not found
1300
+ """
1301
+ owner = owner or self.default_owner
1302
+ if not owner:
1303
+ raise ValueError("Owner must be specified")
1304
+
1305
+ # If project is a number, query directly
1306
+ if project.isdigit():
1307
+ project_number = int(project)
1308
+ if is_org:
1309
+ query = """
1310
+ query($owner: String!, $number: Int!) {
1311
+ organization(login: $owner) {
1312
+ projectV2(number: $number) {
1313
+ id
1314
+ }
1315
+ }
1316
+ }
1317
+ """
1318
+ try:
1319
+ data = self._graphql_request(
1320
+ query, {"owner": owner, "number": project_number}
1321
+ )
1322
+ except GithubException as e:
1323
+ # Project not found - try as user or return None
1324
+ if "Could not resolve" in str(e.data.get("message", "")):
1325
+ return self.get_project_id(
1326
+ project=project, owner=owner, is_org=False
1327
+ )
1328
+ raise
1329
+ org = data.get("organization")
1330
+ if not org:
1331
+ # Try as user
1332
+ return self.get_project_id(
1333
+ project=project, owner=owner, is_org=False
1334
+ )
1335
+ project_data = org.get("projectV2")
1336
+ return project_data["id"] if project_data else None
1337
+ else:
1338
+ query = """
1339
+ query($owner: String!, $number: Int!) {
1340
+ user(login: $owner) {
1341
+ projectV2(number: $number) {
1342
+ id
1343
+ }
1344
+ }
1345
+ }
1346
+ """
1347
+ try:
1348
+ data = self._graphql_request(
1349
+ query, {"owner": owner, "number": project_number}
1350
+ )
1351
+ except GithubException as e:
1352
+ # Project not found
1353
+ if "Could not resolve" in str(e.data.get("message", "")):
1354
+ return None
1355
+ raise
1356
+ user = data.get("user")
1357
+ if not user:
1358
+ return None
1359
+ project_data = user.get("projectV2")
1360
+ return project_data["id"] if project_data else None
1361
+
1362
+ # Project is a title - search through list
1363
+ projects = self.list_projects(owner=owner, is_org=is_org, limit=50)
1364
+ for p in projects:
1365
+ if p["title"].lower() == project.lower():
1366
+ return p["id"]
1367
+
1368
+ return None
1369
+
1370
+ def get_issue_node_id(
1371
+ self,
1372
+ issue_number: int,
1373
+ owner: Optional[str] = None,
1374
+ repo: Optional[str] = None,
1375
+ ) -> str:
1376
+ """
1377
+ Get the node ID of an issue (required for GraphQL mutations).
1378
+
1379
+ Args:
1380
+ issue_number: Issue number
1381
+ owner: Repository owner
1382
+ repo: Repository name
1383
+
1384
+ Returns:
1385
+ Issue node ID
1386
+ """
1387
+ owner = owner or self.default_owner
1388
+ repo = repo or self.default_repo
1389
+ if not owner or not repo:
1390
+ raise ValueError("Repository owner and name must be specified")
1391
+
1392
+ query = """
1393
+ query($owner: String!, $repo: String!, $number: Int!) {
1394
+ repository(owner: $owner, name: $repo) {
1395
+ issue(number: $number) {
1396
+ id
1397
+ }
1398
+ }
1399
+ }
1400
+ """
1401
+ data = self._graphql_request(
1402
+ query, {"owner": owner, "repo": repo, "number": issue_number}
1403
+ )
1404
+ repository = data.get("repository")
1405
+ if not repository:
1406
+ raise GithubException(
1407
+ 404, {"message": f"Repository {owner}/{repo} not found"}
1408
+ )
1409
+ issue = repository.get("issue")
1410
+ if not issue:
1411
+ raise GithubException(404, {"message": f"Issue #{issue_number} not found"})
1412
+ return issue["id"]
1413
+
1414
+ def add_issue_to_project(
1415
+ self,
1416
+ issue_number: int,
1417
+ project: str,
1418
+ owner: Optional[str] = None,
1419
+ repo: Optional[str] = None,
1420
+ project_owner: Optional[str] = None,
1421
+ ) -> Dict[str, Any]:
1422
+ """
1423
+ Add an issue to a GitHub Project V2.
1424
+
1425
+ Args:
1426
+ issue_number: Issue number to add
1427
+ project: Project number (as string) or title
1428
+ owner: Repository owner (for the issue)
1429
+ repo: Repository name
1430
+ project_owner: Owner of the project (org or user). Defaults to repo owner.
1431
+
1432
+ Returns:
1433
+ Dict with project_item_id and success status
1434
+ """
1435
+ owner = owner or self.default_owner
1436
+ repo = repo or self.default_repo
1437
+ project_owner = project_owner or owner
1438
+
1439
+ if not owner or not repo:
1440
+ raise ValueError("Repository owner and name must be specified")
1441
+
1442
+ # Get issue node ID
1443
+ issue_node_id = self.get_issue_node_id(issue_number, owner=owner, repo=repo)
1444
+
1445
+ # Get project node ID
1446
+ project_id = self.get_project_id(project, owner=project_owner, is_org=True)
1447
+ if not project_id:
1448
+ raise GithubException(
1449
+ 404, {"message": f"Project '{project}' not found for {project_owner}"}
1450
+ )
1451
+
1452
+ # Add to project using mutation
1453
+ mutation = """
1454
+ mutation($projectId: ID!, $contentId: ID!) {
1455
+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
1456
+ item {
1457
+ id
1458
+ }
1459
+ }
1460
+ }
1461
+ """
1462
+ data = self._graphql_request(
1463
+ mutation, {"projectId": project_id, "contentId": issue_node_id}
1464
+ )
1465
+
1466
+ item = data.get("addProjectV2ItemById", {}).get("item")
1467
+ return {
1468
+ "success": True,
1469
+ "issue_number": issue_number,
1470
+ "project": project,
1471
+ "project_item_id": item["id"] if item else None,
1472
+ }
1473
+
1474
+ def remove_issue_from_project(
1475
+ self,
1476
+ issue_number: int,
1477
+ project: str,
1478
+ owner: Optional[str] = None,
1479
+ repo: Optional[str] = None,
1480
+ project_owner: Optional[str] = None,
1481
+ ) -> Dict[str, Any]:
1482
+ """
1483
+ Remove an issue from a GitHub Project V2.
1484
+
1485
+ Args:
1486
+ issue_number: Issue number to remove
1487
+ project: Project number (as string) or title
1488
+ owner: Repository owner (for the issue)
1489
+ repo: Repository name
1490
+ project_owner: Owner of the project (org or user). Defaults to repo owner.
1491
+
1492
+ Returns:
1493
+ Dict with success status
1494
+ """
1495
+ owner = owner or self.default_owner
1496
+ repo = repo or self.default_repo
1497
+ project_owner = project_owner or owner
1498
+
1499
+ if not owner or not repo:
1500
+ raise ValueError("Repository owner and name must be specified")
1501
+
1502
+ # Get project node ID
1503
+ project_id = self.get_project_id(project, owner=project_owner, is_org=True)
1504
+ if not project_id:
1505
+ raise GithubException(
1506
+ 404, {"message": f"Project '{project}' not found for {project_owner}"}
1507
+ )
1508
+
1509
+ # Get issue node ID
1510
+ issue_node_id = self.get_issue_node_id(issue_number, owner=owner, repo=repo)
1511
+
1512
+ # First, find the project item ID for this issue
1513
+ query = """
1514
+ query($projectId: ID!, $cursor: String) {
1515
+ node(id: $projectId) {
1516
+ ... on ProjectV2 {
1517
+ items(first: 100, after: $cursor) {
1518
+ nodes {
1519
+ id
1520
+ content {
1521
+ ... on Issue {
1522
+ id
1523
+ number
1524
+ }
1525
+ }
1526
+ }
1527
+ pageInfo {
1528
+ hasNextPage
1529
+ endCursor
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ }
1535
+ """
1536
+
1537
+ # Paginate to find the item
1538
+ cursor = None
1539
+ item_id = None
1540
+ while True:
1541
+ data = self._graphql_request(
1542
+ query, {"projectId": project_id, "cursor": cursor}
1543
+ )
1544
+ node = data.get("node", {})
1545
+ items = node.get("items", {})
1546
+
1547
+ for item in items.get("nodes", []):
1548
+ content = item.get("content")
1549
+ if content and content.get("id") == issue_node_id:
1550
+ item_id = item["id"]
1551
+ break
1552
+
1553
+ if item_id:
1554
+ break
1555
+
1556
+ page_info = items.get("pageInfo", {})
1557
+ if not page_info.get("hasNextPage"):
1558
+ break
1559
+ cursor = page_info.get("endCursor")
1560
+
1561
+ if not item_id:
1562
+ raise GithubException(
1563
+ 404,
1564
+ {"message": f"Issue #{issue_number} not found in project '{project}'"},
1565
+ )
1566
+
1567
+ # Delete the item from project
1568
+ mutation = """
1569
+ mutation($projectId: ID!, $itemId: ID!) {
1570
+ deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) {
1571
+ deletedItemId
1572
+ }
1573
+ }
1574
+ """
1575
+ data = self._graphql_request(
1576
+ mutation, {"projectId": project_id, "itemId": item_id}
1577
+ )
1578
+
1579
+ deleted_id = data.get("deleteProjectV2Item", {}).get("deletedItemId")
1580
+ return {
1581
+ "success": True,
1582
+ "issue_number": issue_number,
1583
+ "project": project,
1584
+ "deleted_item_id": deleted_id,
1585
+ }
1586
+
1587
+ def get_project_fields(
1588
+ self,
1589
+ project: str,
1590
+ owner: Optional[str] = None,
1591
+ is_org: bool = True,
1592
+ ) -> List[Dict[str, Any]]:
1593
+ """
1594
+ Get fields for a GitHub Project V2 with options for SingleSelect fields.
1595
+
1596
+ Args:
1597
+ project: Project number (as string) or title
1598
+ owner: Organization or user name
1599
+ is_org: If True, treat owner as organization
1600
+
1601
+ Returns:
1602
+ List of field dicts with id, name, data_type, and options for SingleSelect
1603
+ """
1604
+ owner = owner or self.default_owner
1605
+ if not owner:
1606
+ raise ValueError("Owner must be specified")
1607
+
1608
+ # Get project ID first
1609
+ project_id = self.get_project_id(project, owner=owner, is_org=is_org)
1610
+ if not project_id:
1611
+ raise GithubException(
1612
+ 404, {"message": f"Project '{project}' not found for {owner}"}
1613
+ )
1614
+
1615
+ # Query fields with options
1616
+ query = """
1617
+ query($projectId: ID!) {
1618
+ node(id: $projectId) {
1619
+ ... on ProjectV2 {
1620
+ fields(first: 100) {
1621
+ nodes {
1622
+ ... on ProjectV2FieldCommon {
1623
+ id
1624
+ name
1625
+ dataType
1626
+ }
1627
+ ... on ProjectV2SingleSelectField {
1628
+ options {
1629
+ id
1630
+ name
1631
+ }
1632
+ }
1633
+ }
1634
+ }
1635
+ }
1636
+ }
1637
+ }
1638
+ """
1639
+ data = self._graphql_request(query, {"projectId": project_id})
1640
+
1641
+ node = data.get("node")
1642
+ if not node:
1643
+ return []
1644
+
1645
+ fields = []
1646
+ for field in node.get("fields", {}).get("nodes", []):
1647
+ if not field:
1648
+ continue
1649
+
1650
+ field_data = {
1651
+ "id": field.get("id"),
1652
+ "name": field.get("name"),
1653
+ "data_type": field.get("dataType"),
1654
+ }
1655
+
1656
+ # Add options for SingleSelect fields
1657
+ if "options" in field:
1658
+ field_data["options"] = [
1659
+ {"id": opt["id"], "name": opt["name"]}
1660
+ for opt in field.get("options", [])
1661
+ ]
1662
+
1663
+ fields.append(field_data)
1664
+
1665
+ return fields
1666
+
1667
+ def list_projects_with_fields(
1668
+ self,
1669
+ owner: Optional[str] = None,
1670
+ is_org: bool = True,
1671
+ limit: int = 20,
1672
+ ) -> List[Dict[str, Any]]:
1673
+ """
1674
+ List GitHub Projects V2 with their fields in one GraphQL call.
1675
+
1676
+ More efficient than calling list_projects + get_project_fields separately.
1677
+
1678
+ Args:
1679
+ owner: Organization or user name. Uses default_owner if not specified.
1680
+ is_org: If True, treat owner as organization. If False, treat as user.
1681
+ limit: Maximum projects to return (default: 20)
1682
+
1683
+ Returns:
1684
+ List of project dicts with id, number, title, url, closed, owner, fields
1685
+ """
1686
+ owner = owner or self.default_owner
1687
+ if not owner:
1688
+ raise ValueError("Owner must be specified for listing projects")
1689
+
1690
+ if is_org:
1691
+ query = """
1692
+ query($owner: String!, $limit: Int!) {
1693
+ organization(login: $owner) {
1694
+ projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
1695
+ nodes {
1696
+ id
1697
+ number
1698
+ title
1699
+ url
1700
+ closed
1701
+ fields(first: 50) {
1702
+ nodes {
1703
+ ... on ProjectV2FieldCommon {
1704
+ id
1705
+ name
1706
+ dataType
1707
+ }
1708
+ ... on ProjectV2SingleSelectField {
1709
+ options {
1710
+ id
1711
+ name
1712
+ }
1713
+ }
1714
+ }
1715
+ }
1716
+ }
1717
+ }
1718
+ }
1719
+ }
1720
+ """
1721
+ data = self._graphql_request(query, {"owner": owner, "limit": limit})
1722
+ org = data.get("organization")
1723
+ if not org:
1724
+ # Try as user if org lookup fails
1725
+ return self.list_projects_with_fields(
1726
+ owner=owner, is_org=False, limit=limit
1727
+ )
1728
+ nodes = org.get("projectsV2", {}).get("nodes", [])
1729
+ else:
1730
+ query = """
1731
+ query($owner: String!, $limit: Int!) {
1732
+ user(login: $owner) {
1733
+ projectsV2(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}) {
1734
+ nodes {
1735
+ id
1736
+ number
1737
+ title
1738
+ url
1739
+ closed
1740
+ fields(first: 50) {
1741
+ nodes {
1742
+ ... on ProjectV2FieldCommon {
1743
+ id
1744
+ name
1745
+ dataType
1746
+ }
1747
+ ... on ProjectV2SingleSelectField {
1748
+ options {
1749
+ id
1750
+ name
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1756
+ }
1757
+ }
1758
+ }
1759
+ """
1760
+ data = self._graphql_request(query, {"owner": owner, "limit": limit})
1761
+ user = data.get("user")
1762
+ if not user:
1763
+ return []
1764
+ nodes = user.get("projectsV2", {}).get("nodes", [])
1765
+
1766
+ projects = []
1767
+ for node in nodes:
1768
+ if not node:
1769
+ continue
1770
+
1771
+ # Parse fields
1772
+ fields = []
1773
+ for field in node.get("fields", {}).get("nodes", []):
1774
+ if not field:
1775
+ continue
1776
+
1777
+ field_data = {
1778
+ "id": field.get("id"),
1779
+ "name": field.get("name"),
1780
+ "data_type": field.get("dataType"),
1781
+ }
1782
+
1783
+ # Add options for SingleSelect fields
1784
+ if "options" in field:
1785
+ field_data["options"] = [
1786
+ {"id": opt["id"], "name": opt["name"]}
1787
+ for opt in field.get("options", [])
1788
+ ]
1789
+
1790
+ fields.append(field_data)
1791
+
1792
+ projects.append(
1793
+ {
1794
+ "id": node["id"],
1795
+ "number": node["number"],
1796
+ "title": node["title"],
1797
+ "url": node["url"],
1798
+ "closed": node["closed"],
1799
+ "owner": owner,
1800
+ "fields": fields,
1801
+ }
1802
+ )
1803
+
1804
+ return projects
1805
+
1806
+ def get_project_item_id(
1807
+ self,
1808
+ issue_number: int,
1809
+ project: str,
1810
+ owner: Optional[str] = None,
1811
+ repo: Optional[str] = None,
1812
+ project_owner: Optional[str] = None,
1813
+ ) -> Optional[str]:
1814
+ """
1815
+ Get the project item ID for an issue in a project.
1816
+
1817
+ The project item ID is required for updating field values.
1818
+
1819
+ Args:
1820
+ issue_number: Issue number
1821
+ project: Project number (as string) or title
1822
+ owner: Repository owner (for the issue)
1823
+ repo: Repository name
1824
+ project_owner: Owner of the project (org or user). Defaults to repo owner.
1825
+
1826
+ Returns:
1827
+ Project item ID or None if issue not in project
1828
+ """
1829
+ owner = owner or self.default_owner
1830
+ repo = repo or self.default_repo
1831
+ project_owner = project_owner or owner
1832
+
1833
+ if not owner or not repo:
1834
+ raise ValueError("Repository owner and name must be specified")
1835
+
1836
+ # Get project ID
1837
+ project_id = self.get_project_id(project, owner=project_owner, is_org=True)
1838
+ if not project_id:
1839
+ raise GithubException(
1840
+ 404, {"message": f"Project '{project}' not found for {project_owner}"}
1841
+ )
1842
+
1843
+ # Get issue node ID
1844
+ issue_node_id = self.get_issue_node_id(issue_number, owner=owner, repo=repo)
1845
+
1846
+ # Search for the item in the project
1847
+ query = """
1848
+ query($projectId: ID!, $cursor: String) {
1849
+ node(id: $projectId) {
1850
+ ... on ProjectV2 {
1851
+ items(first: 100, after: $cursor) {
1852
+ nodes {
1853
+ id
1854
+ content {
1855
+ ... on Issue {
1856
+ id
1857
+ number
1858
+ }
1859
+ }
1860
+ }
1861
+ pageInfo {
1862
+ hasNextPage
1863
+ endCursor
1864
+ }
1865
+ }
1866
+ }
1867
+ }
1868
+ }
1869
+ """
1870
+
1871
+ cursor = None
1872
+ while True:
1873
+ data = self._graphql_request(
1874
+ query, {"projectId": project_id, "cursor": cursor}
1875
+ )
1876
+ node = data.get("node", {})
1877
+ items = node.get("items", {})
1878
+
1879
+ for item in items.get("nodes", []):
1880
+ content = item.get("content")
1881
+ if content and content.get("id") == issue_node_id:
1882
+ return item["id"]
1883
+
1884
+ page_info = items.get("pageInfo", {})
1885
+ if not page_info.get("hasNextPage"):
1886
+ break
1887
+ cursor = page_info.get("endCursor")
1888
+
1889
+ return None
1890
+
1891
+ def update_project_item_field(
1892
+ self,
1893
+ issue_number: int,
1894
+ project: str,
1895
+ field_name: str,
1896
+ value: str,
1897
+ owner: Optional[str] = None,
1898
+ repo: Optional[str] = None,
1899
+ project_owner: Optional[str] = None,
1900
+ ) -> Dict[str, Any]:
1901
+ """
1902
+ Update a field value for an issue in a GitHub Project V2.
1903
+
1904
+ Supports different field types:
1905
+ - SINGLE_SELECT: value is the option name (e.g., "In Progress")
1906
+ - TEXT: value is the text content
1907
+ - NUMBER: value is the number as string
1908
+ - DATE: value is ISO date format (YYYY-MM-DD)
1909
+
1910
+ Args:
1911
+ issue_number: Issue number
1912
+ project: Project number (as string) or title
1913
+ field_name: Field name (e.g., "Status", "Priority")
1914
+ value: Field value (option name for SingleSelect, text for others)
1915
+ owner: Repository owner (for the issue)
1916
+ repo: Repository name
1917
+ project_owner: Owner of the project (org or user). Defaults to repo owner.
1918
+
1919
+ Returns:
1920
+ Dict with success status and updated info
1921
+
1922
+ Raises:
1923
+ GithubException: If project, field, or option not found
1924
+ """
1925
+ owner = owner or self.default_owner
1926
+ repo = repo or self.default_repo
1927
+ project_owner = project_owner or owner
1928
+
1929
+ if not owner or not repo:
1930
+ raise ValueError("Repository owner and name must be specified")
1931
+
1932
+ # Get project ID
1933
+ project_id = self.get_project_id(project, owner=project_owner, is_org=True)
1934
+ if not project_id:
1935
+ raise GithubException(
1936
+ 404, {"message": f"Project '{project}' not found for {project_owner}"}
1937
+ )
1938
+
1939
+ # Get project item ID (issue must be in project)
1940
+ item_id = self.get_project_item_id(
1941
+ issue_number=issue_number,
1942
+ project=project,
1943
+ owner=owner,
1944
+ repo=repo,
1945
+ project_owner=project_owner,
1946
+ )
1947
+ if not item_id:
1948
+ raise GithubException(
1949
+ 404,
1950
+ {
1951
+ "message": f"Issue #{issue_number} not found in project '{project}'. Add it first with add_to_project."
1952
+ },
1953
+ )
1954
+
1955
+ # Get fields to find the field ID and type
1956
+ fields = self.get_project_fields(project, owner=project_owner, is_org=True)
1957
+
1958
+ # Find the field by name (case-insensitive)
1959
+ field = None
1960
+ for f in fields:
1961
+ if f["name"].lower() == field_name.lower():
1962
+ field = f
1963
+ break
1964
+
1965
+ if not field:
1966
+ available_fields = [f["name"] for f in fields]
1967
+ raise GithubException(
1968
+ 404,
1969
+ {
1970
+ "message": f"Field '{field_name}' not found in project. Available fields: {available_fields}"
1971
+ },
1972
+ )
1973
+
1974
+ field_id = field["id"]
1975
+ data_type = field.get("data_type")
1976
+
1977
+ # Build the value based on field type
1978
+ if data_type == "SINGLE_SELECT":
1979
+ # Find the option ID by name
1980
+ options = field.get("options", [])
1981
+ option_id = None
1982
+ for opt in options:
1983
+ if opt["name"].lower() == value.lower():
1984
+ option_id = opt["id"]
1985
+ break
1986
+
1987
+ if not option_id:
1988
+ available_options = [opt["name"] for opt in options]
1989
+ raise GithubException(
1990
+ 400,
1991
+ {
1992
+ "message": f"Option '{value}' not found for field '{field_name}'. Available options: {available_options}"
1993
+ },
1994
+ )
1995
+
1996
+ field_value = {"singleSelectOptionId": option_id}
1997
+
1998
+ elif data_type == "TEXT":
1999
+ field_value = {"text": value}
2000
+
2001
+ elif data_type == "NUMBER":
2002
+ try:
2003
+ field_value = {"number": float(value)}
2004
+ except ValueError:
2005
+ raise GithubException(
2006
+ 400,
2007
+ {
2008
+ "message": f"Invalid number value '{value}' for field '{field_name}'"
2009
+ },
2010
+ )
2011
+
2012
+ elif data_type == "DATE":
2013
+ field_value = {"date": value}
2014
+
2015
+ else:
2016
+ raise GithubException(
2017
+ 400,
2018
+ {
2019
+ "message": f"Field type '{data_type}' not supported for updates. Supported: SINGLE_SELECT, TEXT, NUMBER, DATE"
2020
+ },
2021
+ )
2022
+
2023
+ # Execute the mutation
2024
+ mutation = """
2025
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
2026
+ updateProjectV2ItemFieldValue(input: {
2027
+ projectId: $projectId,
2028
+ itemId: $itemId,
2029
+ fieldId: $fieldId,
2030
+ value: $value
2031
+ }) {
2032
+ projectV2Item {
2033
+ id
2034
+ }
2035
+ }
2036
+ }
2037
+ """
2038
+
2039
+ data = self._graphql_request(
2040
+ mutation,
2041
+ {
2042
+ "projectId": project_id,
2043
+ "itemId": item_id,
2044
+ "fieldId": field_id,
2045
+ "value": field_value,
2046
+ },
2047
+ )
2048
+
2049
+ updated_item = data.get("updateProjectV2ItemFieldValue", {}).get(
2050
+ "projectV2Item"
2051
+ )
2052
+
2053
+ return {
2054
+ "success": True,
2055
+ "issue_number": issue_number,
2056
+ "project": project,
2057
+ "field": field_name,
2058
+ "value": value,
2059
+ "item_id": updated_item["id"] if updated_item else item_id,
2060
+ }
@@ -114,3 +114,127 @@ def create_github_resources(mcp: FastMCP) -> None:
114
114
  )
115
115
 
116
116
  return "\n".join(lines)
117
+
118
+ @mcp.resource("github://projects")
119
+ def get_github_projects() -> str:
120
+ """
121
+ List of GitHub Projects V2 with their fields and options.
122
+
123
+ Use these for project management operations like updating issue status.
124
+ """
125
+ store = get_credential_store()
126
+
127
+ # Check if authenticated via PAT or QuickCall
128
+ pat_token, pat_source = get_github_pat()
129
+ has_pat = pat_token is not None
130
+
131
+ if not has_pat and not store.is_authenticated():
132
+ return "GitHub not connected. Options:\n- Run connect_github_via_pat with a Personal Access Token\n- Run connect_quickcall to use QuickCall"
133
+
134
+ # Check QuickCall GitHub App connection
135
+ has_app = False
136
+ if store.is_authenticated():
137
+ creds = store.get_api_credentials()
138
+ if creds and creds.github_connected and creds.github_token:
139
+ has_app = True
140
+
141
+ if not has_pat and not has_app:
142
+ return "GitHub not connected. Connect at quickcall.dev/assistant or use connect_github_via_pat."
143
+
144
+ try:
145
+ # Import here to avoid circular imports
146
+ from mcp_server.tools.github_tools import _get_client
147
+
148
+ client = _get_client()
149
+
150
+ # Determine auth mode for display
151
+ auth_mode = "PAT" if has_pat else "GitHub App"
152
+
153
+ # Get the authenticated user
154
+ username = client.get_authenticated_user()
155
+
156
+ # Collect all projects from user and their orgs
157
+ all_projects = []
158
+
159
+ # 1. Try user projects first
160
+ try:
161
+ user_projects = client.list_projects_with_fields(
162
+ owner=username, is_org=False, limit=100
163
+ )
164
+ all_projects.extend(user_projects)
165
+ except Exception:
166
+ pass
167
+
168
+ # 2. Get unique orgs from repos the user has access to
169
+ try:
170
+ repos = client.list_repos(limit=100)
171
+ org_counts: dict = {}
172
+ for repo in repos:
173
+ if repo.owner != username:
174
+ org_counts[repo.owner] = org_counts.get(repo.owner, 0) + 1
175
+
176
+ # Sort orgs by repo count (most repos first)
177
+ sorted_orgs = sorted(
178
+ org_counts.keys(), key=lambda x: org_counts[x], reverse=True
179
+ )
180
+
181
+ # Fetch projects from all orgs
182
+ for org in sorted_orgs:
183
+ try:
184
+ org_projects = client.list_projects_with_fields(
185
+ owner=org, is_org=True, limit=100
186
+ )
187
+ all_projects.extend(org_projects)
188
+ except Exception:
189
+ pass
190
+ except Exception:
191
+ pass
192
+
193
+ if not all_projects:
194
+ return (
195
+ f"GitHub Projects (via {auth_mode}):\n\n"
196
+ f"No projects found for {username} or accessible orgs.\n\n"
197
+ "To list projects for a specific org, use:\n"
198
+ " manage_issues(action='list_projects', owner='org-name')"
199
+ )
200
+
201
+ projects = all_projects
202
+
203
+ lines = [f"GitHub Projects (via {auth_mode}):", ""]
204
+
205
+ for proj in projects:
206
+ status = "closed" if proj["closed"] else "open"
207
+ lines.append(f"- #{proj['number']}: {proj['title']} ({status})")
208
+ lines.append(f" URL: {proj['url']}")
209
+
210
+ # Show fields
211
+ fields = proj.get("fields", [])
212
+ if fields:
213
+ lines.append(" Fields:")
214
+ for field in fields:
215
+ name = field.get("name", "Unknown")
216
+ data_type = field.get("data_type", "UNKNOWN")
217
+
218
+ if data_type == "SINGLE_SELECT":
219
+ options = field.get("options", [])
220
+ option_names = [opt["name"] for opt in options]
221
+ lines.append(
222
+ f" - {name} (SINGLE_SELECT): {', '.join(option_names)}"
223
+ )
224
+ else:
225
+ lines.append(f" - {name} ({data_type})")
226
+
227
+ lines.append("")
228
+
229
+ lines.append("Usage:")
230
+ lines.append(
231
+ " manage_projects(action='update_fields', issue_numbers=[42], project='1',"
232
+ )
233
+ lines.append(
234
+ " fields={'Status': 'In Progress', 'Priority': 'High'})"
235
+ )
236
+
237
+ return "\n".join(lines)
238
+ except Exception as e:
239
+ logger.error(f"Failed to fetch GitHub projects: {e}")
240
+ return f"Error fetching projects: {str(e)}"
@@ -575,7 +575,7 @@ def create_github_tools(mcp: FastMCP) -> None:
575
575
  ),
576
576
  issue_numbers: Optional[List[int]] = Field(
577
577
  default=None,
578
- description="Issue number(s). Required for view/update/close/reopen/comment/sub-issue ops.",
578
+ description="Issue number(s). Required for view/update/close/reopen/comment/sub-issue/project ops.",
579
579
  ),
580
580
  title: Optional[str] = Field(
581
581
  default=None,
@@ -647,6 +647,8 @@ def create_github_tools(mcp: FastMCP) -> None:
647
647
  - add sub-issues: manage_issues(action="add_sub_issue", issue_numbers=[43,44], parent_issue=42)
648
648
  - remove sub-issue: manage_issues(action="remove_sub_issue", issue_numbers=[43], parent_issue=42)
649
649
  - list sub-issues: manage_issues(action="list_sub_issues", parent_issue=42)
650
+
651
+ For project operations (add to project, update fields), use manage_projects() instead.
650
652
  """
651
653
  try:
652
654
  client = _get_client()
@@ -1091,3 +1093,237 @@ def create_github_tools(mcp: FastMCP) -> None:
1091
1093
  "connected": False,
1092
1094
  "error": str(e),
1093
1095
  }
1096
+
1097
+ @mcp.tool(tags={"github", "projects"})
1098
+ def manage_projects(
1099
+ action: str = Field(
1100
+ ...,
1101
+ description="Action: 'list', 'add', 'remove', 'update_fields'",
1102
+ ),
1103
+ issue_numbers: Optional[List[int]] = Field(
1104
+ default=None,
1105
+ description="Issue number(s) for add/remove/update_fields actions.",
1106
+ ),
1107
+ project: Optional[str] = Field(
1108
+ default=None,
1109
+ description="Project number or title. Required for add/remove/update_fields.",
1110
+ ),
1111
+ fields: Optional[Dict[str, str]] = Field(
1112
+ default=None,
1113
+ description="Dict of field names to values for 'add' or 'update_fields'. "
1114
+ "Example: {'Status': 'In Progress', 'Priority': 'High'}",
1115
+ ),
1116
+ owner: Optional[str] = Field(
1117
+ default=None,
1118
+ description="Repository owner (for the issues).",
1119
+ ),
1120
+ repo: Optional[str] = Field(
1121
+ default=None,
1122
+ description="Repository name (for the issues).",
1123
+ ),
1124
+ project_owner: Optional[str] = Field(
1125
+ default=None,
1126
+ description="Owner of the project (org or user). Defaults to repo owner.",
1127
+ ),
1128
+ limit: Optional[int] = Field(
1129
+ default=20,
1130
+ description="Maximum projects to return for 'list' action.",
1131
+ ),
1132
+ ) -> dict:
1133
+ """
1134
+ Manage GitHub Projects V2: list projects, add/remove issues, update fields.
1135
+
1136
+ IMPORTANT: Use 'add' with 'fields' to add issue AND set fields in ONE call.
1137
+ Don't make separate calls for add + update_fields.
1138
+
1139
+ Examples:
1140
+ - list: manage_projects(action="list", owner="org-name")
1141
+ - add with fields: manage_projects(action="add", issue_numbers=[42], project="1",
1142
+ fields={"Status": "Triage", "Priority": "High"}, repo="my-repo")
1143
+ - add only: manage_projects(action="add", issue_numbers=[42], project="1", repo="my-repo")
1144
+ - remove: manage_projects(action="remove", issue_numbers=[42], project="1", repo="my-repo")
1145
+ - update fields: manage_projects(action="update_fields", issue_numbers=[42], project="1",
1146
+ fields={"Status": "In Progress"}, repo="my-repo")
1147
+ """
1148
+ try:
1149
+ client = _get_client()
1150
+
1151
+ # === LIST ACTION ===
1152
+ if action == "list":
1153
+ proj_owner = project_owner or owner
1154
+ if not proj_owner:
1155
+ # Use authenticated user if no owner specified
1156
+ proj_owner = client.get_authenticated_user()
1157
+
1158
+ projects = client.list_projects(
1159
+ owner=proj_owner,
1160
+ is_org=True, # Try org first, falls back to user
1161
+ limit=limit or 20,
1162
+ )
1163
+ return {
1164
+ "action": "list",
1165
+ "owner": proj_owner,
1166
+ "count": len(projects),
1167
+ "projects": projects,
1168
+ }
1169
+
1170
+ # === ADD ACTION ===
1171
+ if action == "add":
1172
+ if not project:
1173
+ raise ToolError("'project' is required for 'add' action")
1174
+ if not issue_numbers:
1175
+ raise ToolError("'issue_numbers' is required for 'add' action")
1176
+
1177
+ results = []
1178
+ for issue_number in issue_numbers:
1179
+ issue_result = {
1180
+ "number": issue_number,
1181
+ "project": project,
1182
+ }
1183
+ try:
1184
+ result = client.add_issue_to_project(
1185
+ issue_number=issue_number,
1186
+ project=project,
1187
+ owner=owner,
1188
+ repo=repo,
1189
+ project_owner=project_owner,
1190
+ )
1191
+ issue_result["status"] = "added"
1192
+ issue_result["project_item_id"] = result.get("project_item_id")
1193
+
1194
+ # If fields provided, set them after adding
1195
+ if fields:
1196
+ issue_result["fields_updated"] = []
1197
+ issue_result["field_errors"] = []
1198
+ for field_name, value in fields.items():
1199
+ try:
1200
+ client.update_project_item_field(
1201
+ issue_number=issue_number,
1202
+ project=project,
1203
+ field_name=field_name,
1204
+ value=value,
1205
+ owner=owner,
1206
+ repo=repo,
1207
+ project_owner=project_owner,
1208
+ )
1209
+ issue_result["fields_updated"].append(
1210
+ {"field": field_name, "value": value}
1211
+ )
1212
+ except Exception as e:
1213
+ issue_result["field_errors"].append(
1214
+ {"field": field_name, "error": str(e)}
1215
+ )
1216
+
1217
+ results.append(issue_result)
1218
+ except Exception as e:
1219
+ issue_result["status"] = "error"
1220
+ issue_result["error"] = str(e)
1221
+ results.append(issue_result)
1222
+
1223
+ return {
1224
+ "action": "add",
1225
+ "project": project,
1226
+ "count": len(results),
1227
+ "results": results,
1228
+ }
1229
+
1230
+ # === REMOVE ACTION ===
1231
+ if action == "remove":
1232
+ if not project:
1233
+ raise ToolError("'project' is required for 'remove' action")
1234
+ if not issue_numbers:
1235
+ raise ToolError("'issue_numbers' is required for 'remove' action")
1236
+
1237
+ results = []
1238
+ for issue_number in issue_numbers:
1239
+ try:
1240
+ client.remove_issue_from_project(
1241
+ issue_number=issue_number,
1242
+ project=project,
1243
+ owner=owner,
1244
+ repo=repo,
1245
+ project_owner=project_owner,
1246
+ )
1247
+ results.append(
1248
+ {
1249
+ "number": issue_number,
1250
+ "status": "removed",
1251
+ "project": project,
1252
+ }
1253
+ )
1254
+ except Exception as e:
1255
+ results.append(
1256
+ {
1257
+ "number": issue_number,
1258
+ "status": "error",
1259
+ "error": str(e),
1260
+ }
1261
+ )
1262
+
1263
+ return {
1264
+ "action": "remove",
1265
+ "project": project,
1266
+ "count": len(results),
1267
+ "results": results,
1268
+ }
1269
+
1270
+ # === UPDATE FIELDS ACTION ===
1271
+ if action == "update_fields":
1272
+ if not project:
1273
+ raise ToolError("'project' is required for 'update_fields' action")
1274
+ if not issue_numbers:
1275
+ raise ToolError(
1276
+ "'issue_numbers' is required for 'update_fields' action"
1277
+ )
1278
+ if not fields:
1279
+ raise ToolError("'fields' is required for 'update_fields' action")
1280
+
1281
+ results = []
1282
+ for issue_number in issue_numbers:
1283
+ issue_result = {
1284
+ "number": issue_number,
1285
+ "fields_updated": [],
1286
+ "errors": [],
1287
+ }
1288
+
1289
+ for field_name, value in fields.items():
1290
+ try:
1291
+ client.update_project_item_field(
1292
+ issue_number=issue_number,
1293
+ project=project,
1294
+ field_name=field_name,
1295
+ value=value,
1296
+ owner=owner,
1297
+ repo=repo,
1298
+ project_owner=project_owner,
1299
+ )
1300
+ issue_result["fields_updated"].append(
1301
+ {"field": field_name, "value": value}
1302
+ )
1303
+ except Exception as e:
1304
+ issue_result["errors"].append(
1305
+ {"field": field_name, "error": str(e)}
1306
+ )
1307
+
1308
+ issue_result["status"] = (
1309
+ "success" if not issue_result["errors"] else "partial"
1310
+ )
1311
+ results.append(issue_result)
1312
+
1313
+ return {
1314
+ "action": "update_fields",
1315
+ "project": project,
1316
+ "count": len(results),
1317
+ "results": results,
1318
+ }
1319
+
1320
+ raise ToolError(
1321
+ f"Invalid action: {action}. Valid actions: list, add, remove, update_fields"
1322
+ )
1323
+
1324
+ except ToolError:
1325
+ raise
1326
+ except ValueError as e:
1327
+ raise ToolError(f"Invalid parameters: {str(e)}")
1328
+ except Exception as e:
1329
+ raise ToolError(f"Failed to {action} project: {str(e)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickcall-integrations
3
- Version: 0.3.9
3
+ Version: 0.4.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
@@ -1,21 +1,21 @@
1
1
  mcp_server/__init__.py,sha256=6KGzjSPyVB6vQh150DwBjINM_CsZNDhOzwSQFWpXz0U,301
2
2
  mcp_server/server.py,sha256=kv5hh0J-M7yENUBBNI1bkq1y7MB0zn5R_-R1tib6_sk,3108
3
3
  mcp_server/api_clients/__init__.py,sha256=kOG5_sxIVpAx_tvf1nq_P0QCkqojAVidRE-wenLS-Wc,207
4
- mcp_server/api_clients/github_client.py,sha256=Mlh6BzMhZ05NqkX9A2O80eJIQXWuW4FnnN1DBM0WKC8,40135
4
+ mcp_server/api_clients/github_client.py,sha256=Y3P2yRRv8GxG0xjql6IaVB5aodGDeTPQUC_lHQmgVWM,70829
5
5
  mcp_server/api_clients/slack_client.py,sha256=w3rcGghttfYw8Ird2beNo2LEYLc3rCTbUKMH4X7QQuQ,16447
6
6
  mcp_server/auth/__init__.py,sha256=D-JS0Qe7FkeJjYx92u_AqPx8ZRoB3dKMowzzJXlX6cc,780
7
7
  mcp_server/auth/credentials.py,sha256=sDS0W5c16i_UGvhG8Sh1RO93FxRn-hHVAdI9hlWuhx0,20011
8
8
  mcp_server/auth/device_flow.py,sha256=NXNWHzd-CA4dlhEVCgUhwfpe9TpMKpLSJuyFCh70xKs,8371
9
9
  mcp_server/resources/__init__.py,sha256=JrMa3Kf-DmeCB4GwVNfmfw9OGnxF9pJJxCw9Y7u7ujQ,35
10
- mcp_server/resources/github_resources.py,sha256=sXE06j9jrSDODxH2832fiCtY9n1lKBQR8QZ8U5wYbJY,4030
10
+ mcp_server/resources/github_resources.py,sha256=b7S5Jd-xcS6OjceOdwsJZ-BLfIolQXZ9LnQhlhigfb0,8850
11
11
  mcp_server/resources/slack_resources.py,sha256=b_CPxAicwkF3PsBXIat4QoLbDUHM2g_iPzgzvVpwjaw,1687
12
12
  mcp_server/tools/__init__.py,sha256=vIR2ujAaTXm2DgpTsVNz3brI4G34p-Jeg44Qe0uvWc0,405
13
13
  mcp_server/tools/auth_tools.py,sha256=BPuj9M0pZOvvWHxH0HPdiVm-Y6DJyD-PEvtrIh68vbc,25409
14
14
  mcp_server/tools/git_tools.py,sha256=jyCTQR2eSzUFXMt0Y8x66758-VY8YCY14DDUJt7GY2U,13957
15
- mcp_server/tools/github_tools.py,sha256=2YE0RtkfartAuLVUciVFuycqc-B3OhN0uNPwhDm1t48,40053
15
+ mcp_server/tools/github_tools.py,sha256=U1auskC17nJWVmqFO9EITe-OcDSeDBuhM3d4iox7Ix8,50007
16
16
  mcp_server/tools/slack_tools.py,sha256=-HVE_x3Z1KMeYGi1xhyppEwz5ZF-I-ZD0-Up8yBeoYE,11796
17
17
  mcp_server/tools/utility_tools.py,sha256=oxAXpdqtPeB5Ug5dvk54V504r-8v1AO4_px-sO6LFOw,3910
18
- quickcall_integrations-0.3.9.dist-info/METADATA,sha256=thgEYROxcB1cBO4BsAunkrS9BIYtyTmLWTe3Fci5qSI,9415
19
- quickcall_integrations-0.3.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
- quickcall_integrations-0.3.9.dist-info/entry_points.txt,sha256=kkcunmJUzncYvQ1rOR35V2LPm2HcFTKzdI2l3n7NwiM,66
21
- quickcall_integrations-0.3.9.dist-info/RECORD,,
18
+ quickcall_integrations-0.4.0.dist-info/METADATA,sha256=pOcoe0a2JKylPL6tpzYk2lUor_hgfsXPyDgjwjn45JM,9415
19
+ quickcall_integrations-0.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
20
+ quickcall_integrations-0.4.0.dist-info/entry_points.txt,sha256=kkcunmJUzncYvQ1rOR35V2LPm2HcFTKzdI2l3n7NwiM,66
21
+ quickcall_integrations-0.4.0.dist-info/RECORD,,