Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"fastmcp-slim[server,apps,tasks]>=3.4.2",
"fastmcp-slim[server,apps,tasks]>=3.4.4",
"httpx>=0.28.1",
"redis>=8.0.0",
"redis>=8.0.1",
]

[project.scripts]
Expand Down Expand Up @@ -63,10 +63,10 @@ metadata = false

[dependency-groups]
dev = [
"pyright>=1.1.410",
"pytest>=9.1.0",
"ruff>=0.15.17",
"uv>=0.11.21",
"pyright>=1.1.411",
"pytest>=9.1.1",
"ruff>=0.15.21",
"uv>=0.11.28",
]

[tool.ruff]
Expand All @@ -75,8 +75,6 @@ line-length = 120
[tool.ruff.lint]
select = ["D", "PLR", "E", "F", "I", "N", "W", "ANN", "ASYNC", "B", "C4", "DTZ", "ICN", "PIE", "RET", "SLF", "T20", "UP", "YTT"]
ignore = [
"ANN101", # Missing type annotation for self in method
"ANN102", # Missing type annotation for cls in classmethod
"ANN202", # Missing return type annotation for private function
"ANN204", # Missing return type annotation for special method __init__
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
Expand Down
77 changes: 58 additions & 19 deletions src/mcp_github/github_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,21 @@ class PRContent(TypedDict):
class CommentData(TypedDict):
id: int
body: str
user: dict[str, Any]
author: str
html_url: str
created_at: str
updated_at: str


class IssueData(TypedDict):
id: int
number: int
title: str
body: str | None
state: str
user: dict[str, Any]
author: str
labels: list[str]
html_url: str
created_at: str
updated_at: str
labels: list[dict[str, Any]]


class UserSearchResult(TypedDict):
Expand Down Expand Up @@ -142,6 +142,37 @@ class StatusChecksResult(TypedDict):
logging.basicConfig(level=logging.WARNING)


def _pick(data: dict[str, Any], *keys: str) -> dict[str, Any]:
"""Trim a GitHub API payload to the given keys (absent keys become None)."""
return {k: data.get(k) for k in keys}


def _comment_result(data: dict[str, Any]) -> CommentData:
"""Trim a GitHub comment payload to the CommentData contract."""
return {
"id": data["id"],
"body": data["body"],
"author": (data.get("user") or {}).get("login", ""),
"html_url": data["html_url"],
"created_at": data["created_at"],
}


def _issue_result(data: dict[str, Any]) -> IssueData:
"""Trim a GitHub issue payload to the IssueData contract."""
return {
"number": data["number"],
"title": data["title"],
"body": data.get("body"),
"state": data["state"],
"author": (data.get("user") or {}).get("login", ""),
"labels": [label["name"] for label in data.get("labels", [])],
"html_url": data["html_url"],
"created_at": data["created_at"],
"updated_at": data["updated_at"],
}


def _annotate(*, ro: bool = False, destructive: bool = False) -> Any:
def deco(fn: Any = None, *, task: bool = False, idempotent: bool = False) -> Any:
def apply(f: Any) -> Any:
Expand Down Expand Up @@ -319,7 +350,8 @@ async def get_pr_content(self, repo_owner: str, repo_name: str, pr_number: int)
async def add_pr_comments(self, repo_owner: str, repo_name: str, pr_number: int, comment: str) -> CommentData:
"""Adds a comment to a specific pull request."""
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues/{pr_number}/comments"
return (await self._request("POST", url, context=f"PR #{pr_number} comment", json={"body": comment})).json()
data = (await self._request("POST", url, context=f"PR #{pr_number} comment", json={"body": comment})).json()
return _comment_result(data)

@_write
async def add_inline_pr_comment(
Expand All @@ -339,9 +371,10 @@ async def add_inline_pr_comment(
raise ToolError(f"Could not retrieve head SHA for PR #{pr_number}")
review_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}/comments"
payload = {"body": comment_body, "commit_id": commit_id, "path": path, "line": line, "side": "RIGHT"}
return (
data = (
await self._request("POST", review_url, context=f"inline comment on {path}:{line}", json=payload)
).json()
return _comment_result(data)

@_write(idempotent=True)
async def update_pr_description(
Expand Down Expand Up @@ -431,14 +464,15 @@ async def create_issue(
"""Creates a new issue."""
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues"
issue_labels = ["mcp"] if not labels else labels + ["mcp"]
return (
data = (
await self._request(
"POST",
url,
context=f"create issue in {repo_owner}/{repo_name}",
json={"title": title, "body": body, "labels": issue_labels},
)
).json()
return _issue_result(data)

@_write
async def merge_pr(
Expand Down Expand Up @@ -487,14 +521,15 @@ async def update_issue(
) -> IssueData:
"""Updates an existing issue."""
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues/{issue_number}"
return (
data = (
await self._request(
"PATCH",
url,
context=f"issue #{issue_number}",
json={"title": title, "body": body, "labels": labels, "state": state},
)
).json()
return _issue_result(data)

@_write
async def update_reviews(
Expand All @@ -507,9 +542,10 @@ async def update_reviews(
) -> dict[str, Any]:
"""Submits a review for a specific pull request."""
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}/reviews"
return (
data = (
await self._request("POST", url, context=f"PR #{pr_number} review", json={"body": body, "event": event})
).json()
return _pick(data, "id", "state", "body", "html_url", "submitted_at")

@_write(idempotent=True)
async def update_assignees(
Expand All @@ -525,16 +561,18 @@ async def update_assignees(
actual_logins = {u["login"] for u in data.get("assignees", [])}
requested = set(assignees)
missing = requested - actual_logins
result: dict[str, Any] = {
"status": "partial" if missing else "ok",
"assignees_requested": sorted(requested),
"assignees_applied": sorted(actual_logins),
"issue_url": data.get("html_url"),
}
if missing:
logger.warning(f"Some assignees were not applied: {missing}")
return {
"status": "partial",
"message": f"The following assignees could not be applied (not a collaborator or user does not exist): {sorted(missing)}",
"assignees_requested": sorted(requested),
"assignees_applied": sorted(actual_logins),
"issue": data,
}
return data
result["message"] = (
f"The following assignees could not be applied (not a collaborator or user does not exist): {sorted(missing)}"
)
return result

@_read_only
async def get_latest_sha(self, repo_owner: str, repo_name: str) -> str | None:
Expand Down Expand Up @@ -576,7 +614,7 @@ async def create_release(
) -> dict[str, Any]:
"""Creates a new release."""
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases"
return (
data = (
await self._request(
"POST",
url,
Expand All @@ -592,6 +630,7 @@ async def create_release(
},
)
).json()
return _pick(data, "id", "tag_name", "name", "html_url", "draft", "prerelease", "body")

async def _execute_graphql(
self, query: str, variables: dict[str, Any], *, token: str | None = None
Expand Down
Loading