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.
- mcp_server/api_clients/github_client.py +903 -0
- mcp_server/resources/github_resources.py +124 -0
- mcp_server/tools/github_tools.py +237 -1
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.4.0.dist-info}/METADATA +1 -1
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.4.0.dist-info}/RECORD +7 -7
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.4.0.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -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)}"
|
mcp_server/tools/github_tools.py
CHANGED
|
@@ -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,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=
|
|
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=
|
|
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=
|
|
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.
|
|
19
|
-
quickcall_integrations-0.
|
|
20
|
-
quickcall_integrations-0.
|
|
21
|
-
quickcall_integrations-0.
|
|
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,,
|
|
File without changes
|
{quickcall_integrations-0.3.9.dist-info → quickcall_integrations-0.4.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|