quickcall-integrations 0.3.8__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/auth_tools.py +30 -13
- mcp_server/tools/github_tools.py +238 -2
- {quickcall_integrations-0.3.8.dist-info → quickcall_integrations-0.4.0.dist-info}/METADATA +105 -6
- {quickcall_integrations-0.3.8.dist-info → quickcall_integrations-0.4.0.dist-info}/RECORD +8 -8
- {quickcall_integrations-0.3.8.dist-info → quickcall_integrations-0.4.0.dist-info}/WHEEL +0 -0
- {quickcall_integrations-0.3.8.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
|
+
}
|