blob: 2b9c9fce93f322900b9f4eaeb561037d0256c548 [file]
"""gerrit-mcp-server extension for the Gerrit code-owners plugin.
Exposes three MCP tools:
- get_code_owner_status: per-file code owner approval status for a change
- get_code_owners_for_path: code owners for a specific path in a change revision
- check_code_owner: check whether a user is a code owner for a path in a branch
All tools require the 'code-owners' Gerrit plugin via @requires_plugin.
"""
import json
from typing import Any, Dict, List, Optional, TypedDict
from urllib.parse import quote
from gerrit_mcp_server.extensions import ExtensionContext, requires_plugin
class _AccountInfo(TypedDict):
account_id: int
name: Optional[str]
email: Optional[str]
username: Optional[str]
class _PathCodeOwnerStatusInfo(TypedDict):
path: str
status: str
reasons: Optional[List[str]]
class _FileCodeOwnerStatusInfo(TypedDict):
change_type: Optional[str]
old_path_status: Optional[_PathCodeOwnerStatusInfo]
new_path_status: Optional[_PathCodeOwnerStatusInfo]
class _CodeOwnerStatusResult(TypedDict):
change_id: str
patch_set_number: int
file_code_owner_statuses: List[_FileCodeOwnerStatusInfo]
more: Optional[bool]
accounts: Optional[Dict[str, _AccountInfo]]
class _CodeOwnerInfo(TypedDict):
account: _AccountInfo
scorings: Optional[Dict[str, int]]
class _CodeOwnersForPathResult(TypedDict):
change_id: str
revision_id: str
path: str
code_owners: List[_CodeOwnerInfo]
owned_by_all_users: Optional[bool]
class _CheckCodeOwnerResult(TypedDict):
project: str
branch: str
path: str
email: str
is_code_owner: bool
is_resolvable: bool
can_read_ref: Optional[bool]
can_see_change: Optional[bool]
can_approve_change: Optional[bool]
is_fallback_code_owner: Optional[bool]
is_default_code_owner: Optional[bool]
is_global_code_owner: Optional[bool]
is_owned_by_all_users: Optional[bool]
annotation: Optional[List[str]]
def _parse_path_status(
raw: Optional[Dict[str, Any]],
) -> Optional[_PathCodeOwnerStatusInfo]:
if raw is None:
return None
return {
"path": raw.get("path", ""),
"status": raw.get("status", ""),
"reasons": raw.get("reasons") or None,
}
def register(ctx: ExtensionContext) -> None:
"""Register code-owners MCP tools."""
@ctx.mcp.tool()
@requires_plugin("code-owners", ctx.plugin_registry)
async def get_code_owner_status(
change_id: str,
limit: Optional[int] = None,
start: Optional[int] = None,
gerrit_base_url: Optional[str] = None,
) -> _CodeOwnerStatusResult:
"""Return code owner approval status for each file in a change.
The status field for each path is one of:
APPROVED - a code owner approved, or an override is present
PENDING - a code owner is a reviewer but has not approved yet
INSUFFICIENT_REVIEWERS - no code owner has been added as a reviewer
Pass limit and start for pagination when a change touches many files.
"""
base_url = ctx.get_base_url(gerrit_base_url)
url = f"{base_url}/changes/{quote(str(change_id))}/code_owners.status"
params = []
if limit is not None:
params.append(f"n={limit}")
if start is not None:
params.append(f"S={start}")
if params:
url += "?" + "&".join(params)
result_str = await ctx.run_curl([url], base_url)
try:
data = json.loads(result_str)
except json.JSONDecodeError as e:
raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
file_statuses: List[_FileCodeOwnerStatusInfo] = []
for raw_file in data.get("file_code_owner_statuses", []):
entry: _FileCodeOwnerStatusInfo = {
"change_type": raw_file.get("change_type") or None,
"old_path_status": _parse_path_status(raw_file.get("old_path_status")),
"new_path_status": _parse_path_status(raw_file.get("new_path_status")),
}
file_statuses.append(entry)
accounts: Optional[Dict[str, _AccountInfo]] = None
raw_accounts = data.get("accounts")
if raw_accounts:
accounts = {}
for account_id, raw_account in raw_accounts.items():
accounts[account_id] = {
"account_id": raw_account.get("_account_id", 0),
"name": raw_account.get("name") or None,
"email": raw_account.get("email") or None,
"username": raw_account.get("username") or None,
}
return {
"change_id": change_id,
"patch_set_number": data.get("patch_set_number", 0),
"file_code_owner_statuses": file_statuses,
"more": data.get("more") or None,
"accounts": accounts,
}
@ctx.mcp.tool()
@requires_plugin("code-owners", ctx.plugin_registry)
async def get_code_owners_for_path(
change_id: str,
path: str,
revision_id: str = "current",
limit: Optional[int] = None,
gerrit_base_url: Optional[str] = None,
) -> _CodeOwnersForPathResult:
"""Return suggested code owners for a specific path in a change revision.
The plugin filters out the change owner and service users, and ranks
reviewers higher. Use this to find who should review a particular file.
Pass revision_id to target a specific patch set (defaults to 'current').
Pass limit to cap how many owners are returned.
"""
base_url = ctx.get_base_url(gerrit_base_url)
encoded_path = quote(path.lstrip("/"), safe="/")
url = (
f"{base_url}/changes/{quote(str(change_id))}"
f"/revisions/{quote(str(revision_id))}"
f"/code_owners/{encoded_path}"
)
if limit is not None:
url += f"?n={limit}"
result_str = await ctx.run_curl([url], base_url)
try:
data = json.loads(result_str)
except json.JSONDecodeError as e:
raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
owners: List[_CodeOwnerInfo] = []
for raw_owner in data.get("code_owners", []):
raw_account = raw_owner.get("account", {})
account: _AccountInfo = {
"account_id": raw_account.get("_account_id", 0),
"name": raw_account.get("name") or None,
"email": raw_account.get("email") or None,
"username": raw_account.get("username") or None,
}
owner: _CodeOwnerInfo = {
"account": account,
"scorings": raw_owner.get("scorings") or None,
}
owners.append(owner)
return {
"change_id": change_id,
"revision_id": revision_id,
"path": path,
"code_owners": owners,
"owned_by_all_users": data.get("owned_by_all_users") or None,
}
@ctx.mcp.tool()
@requires_plugin("code-owners", ctx.plugin_registry)
async def check_code_owner(
project: str,
branch: str,
path: str,
email: str,
change_id: Optional[str] = None,
gerrit_base_url: Optional[str] = None,
) -> _CheckCodeOwnerResult:
"""Check whether a user (by email) is a code owner for a path in a branch.
Returns detailed ownership information including whether the user can
read the ref, see and approve the change (if change_id is provided),
and which kind of code owner they are (global, default, fallback).
Pass change_id to also check change-level permissions.
"""
base_url = ctx.get_base_url(gerrit_base_url)
encoded_project = quote(project, safe="")
encoded_branch = quote(branch, safe="")
url = (
f"{base_url}/projects/{encoded_project}"
f"/branches/{encoded_branch}"
f"/code_owners.check"
f"?email={quote(email)}&path={quote(path)}"
)
if change_id is not None:
url += f"&change={quote(str(change_id))}"
result_str = await ctx.run_curl([url], base_url)
try:
data = json.loads(result_str)
except json.JSONDecodeError as e:
raise ValueError(f"Could not parse Gerrit response: {result_str}") from e
return {
"project": project,
"branch": branch,
"path": path,
"email": email,
"is_code_owner": data.get("is_code_owner", False),
"is_resolvable": data.get("is_resolvable", False),
"can_read_ref": data.get("can_read_ref"),
"can_see_change": data.get("can_see_change"),
"can_approve_change": data.get("can_approve_change"),
"is_fallback_code_owner": data.get("is_fallback_code_owner"),
"is_default_code_owner": data.get("is_default_code_owner"),
"is_global_code_owner": data.get("is_global_code_owner"),
"is_owned_by_all_users": data.get("is_owned_by_all_users"),
"annotation": data.get("annotation") or None,
}