PyGithub MCP Server
by AstroMined
- src
- pygithub_mcp_server
- schemas
"""Issue-related schema models.
This module defines Pydantic models for GitHub issue operations
including issue creation, updates, comments, and labels.
All models include strict validation to ensure data integrity
before passing to PyGithub methods.
"""
from datetime import datetime
from typing import List, Literal, Optional, Union
from pydantic import BaseModel, Field, field_validator, ConfigDict
from pygithub_mcp_server.converters.common.datetime import convert_iso_string_to_datetime
from .base import RepositoryRef
# Valid values for issue state
VALID_ISSUE_STATES = ["open", "closed", "all"]
# Valid values for sort field
VALID_SORT_VALUES = ["created", "updated", "comments"]
# Valid values for direction
VALID_DIRECTION_VALUES = ["asc", "desc"]
class CreateIssueParams(RepositoryRef):
"""Parameters for creating an issue."""
model_config = ConfigDict(strict=True)
title: str = Field(..., description="Issue title", strict=True)
body: Optional[str] = Field(None, description="Issue description", strict=True)
assignees: List[str] = Field(default_factory=list, description="Usernames to assign", strict=True)
labels: List[str] = Field(default_factory=list, description="Labels to add", strict=True)
milestone: Optional[int] = Field(None, description="Milestone number", strict=True)
@field_validator('title')
@classmethod
def validate_title(cls, v):
"""Validate that title is not empty."""
if not v.strip():
raise ValueError("title cannot be empty")
return v
class ListIssuesParams(RepositoryRef):
"""Parameters for listing issues."""
model_config = ConfigDict(strict=True)
state: Optional[str] = Field(
None,
description=f"Issue state: {', '.join(VALID_ISSUE_STATES)}"
)
labels: Optional[List[str]] = Field(
None,
description="Filter by labels (list of label names)"
)
sort: Optional[str] = Field(
None,
description=f"Sort by: {', '.join(VALID_SORT_VALUES)}"
)
direction: Optional[str] = Field(
None,
description=f"Sort direction: {', '.join(VALID_DIRECTION_VALUES)}"
)
since: Optional[datetime] = Field(
None,
description="Filter by date (ISO 8601 format with timezone: YYYY-MM-DDThh:mm:ssZ)"
)
page: Optional[int] = Field(
None,
description="Page number for pagination (1-based)"
)
per_page: Optional[int] = Field(
None,
description="Results per page (max 100)"
)
@field_validator('state')
@classmethod
def validate_state(cls, v):
"""Validate that state is one of the allowed values."""
if v is not None and v not in VALID_ISSUE_STATES:
raise ValueError(f"Invalid state: {v}. Must be one of: {', '.join(VALID_ISSUE_STATES)}")
return v
@field_validator('sort')
@classmethod
def validate_sort(cls, v):
"""Validate that sort is one of the allowed values."""
if v is not None and v not in VALID_SORT_VALUES:
raise ValueError(f"Invalid sort value: {v}. Must be one of: {', '.join(VALID_SORT_VALUES)}")
return v
@field_validator('direction')
@classmethod
def validate_direction(cls, v):
"""Validate that direction is one of the allowed values."""
if v is not None and v not in VALID_DIRECTION_VALUES:
raise ValueError(f"Invalid direction: {v}. Must be one of: {', '.join(VALID_DIRECTION_VALUES)}")
return v
@field_validator('page')
@classmethod
def validate_page(cls, v):
"""Validate that page is a positive integer."""
if v is not None and v < 1:
raise ValueError("Page number must be a positive integer")
return v
@field_validator('per_page')
@classmethod
def validate_per_page(cls, v):
"""Validate that per_page is a positive integer <= 100."""
if v is not None:
if v < 1:
raise ValueError("Results per page must be a positive integer")
if v > 100:
raise ValueError("Results per page cannot exceed 100")
return v
@field_validator('since', mode='before')
@classmethod
def validate_since(cls, v):
"""Convert string dates to datetime objects.
Accepts:
- ISO 8601 format strings with timezone (e.g., "2020-01-01T00:00:00Z")
- ISO 8601 format strings with timezone without colon (e.g., "2020-01-01T12:30:45-0500")
- ISO 8601 format strings with short timezone (e.g., "2020-01-01T12:30:45+05")
- ISO 8601 format strings with single digit timezone (e.g., "2020-01-01T12:30:45-5")
- datetime objects
Returns:
- datetime object
Raises:
- ValueError: If the string cannot be converted to a valid datetime object
"""
if isinstance(v, str):
# Basic validation - must have 'T' and some form of timezone indicator
if not ('T' in v and ('+' in v or 'Z' in v or '-' in v.split('T')[1])):
raise ValueError(
f"Invalid ISO format datetime: {v}. "
f"Must include date, time with 'T' separator, and timezone."
)
try:
# Try to convert using our flexible converter
return convert_iso_string_to_datetime(v)
except ValueError as e:
# Only raise if conversion actually fails
raise ValueError(f"Invalid ISO format datetime: {v}. {str(e)}")
return v
class GetIssueParams(RepositoryRef):
"""Parameters for getting an issue."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number to retrieve", strict=True)
class UpdateIssueParams(RepositoryRef):
"""Parameters for updating an issue."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number to update")
title: Optional[str] = Field(None, description="New title")
body: Optional[str] = Field(None, description="New description")
state: Optional[str] = Field(
None,
description="New state (open or closed)"
)
labels: Optional[List[str]] = Field(
None,
description="New labels (list of label names)"
)
assignees: Optional[List[str]] = Field(
None,
description="New assignees (list of usernames)"
)
milestone: Optional[int] = Field(
None,
description="New milestone number (or None to clear)"
)
@field_validator('state')
@classmethod
def validate_state(cls, v):
"""Validate that state is one of the allowed values."""
# For update, only open and closed are valid (not 'all')
valid_states = ["open", "closed"]
if v is not None and v not in valid_states:
raise ValueError(f"Invalid state: {v}. Must be one of: {', '.join(valid_states)}")
return v
@field_validator('title')
@classmethod
def validate_title(cls, v):
"""Validate that title is not empty if provided."""
if v is not None and not v.strip():
raise ValueError("title cannot be empty")
return v
class IssueCommentParams(RepositoryRef):
"""Parameters for adding a comment to an issue."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number to comment on")
body: str = Field(..., description="Comment text")
@field_validator('body')
@classmethod
def validate_body(cls, v):
"""Validate that body is not empty."""
if not v.strip():
raise ValueError("body cannot be empty")
return v
class ListIssueCommentsParams(RepositoryRef):
"""Parameters for listing comments on an issue."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number to list comments for")
since: Optional[datetime] = Field(
None,
description="Filter by date (ISO 8601 format with timezone: YYYY-MM-DDThh:mm:ssZ)"
)
page: Optional[int] = Field(
None,
description="Page number for pagination (1-based)"
)
per_page: Optional[int] = Field(
None,
description="Results per page (max 100)"
)
@field_validator('page')
@classmethod
def validate_page(cls, v):
"""Validate that page is a positive integer."""
if v is not None and v < 1:
raise ValueError("Page number must be a positive integer")
return v
@field_validator('per_page')
@classmethod
def validate_per_page(cls, v):
"""Validate that per_page is a positive integer <= 100."""
if v is not None:
if v < 1:
raise ValueError("Results per page must be a positive integer")
if v > 100:
raise ValueError("Results per page cannot exceed 100")
return v
@field_validator('since', mode='before')
@classmethod
def validate_since(cls, v):
"""Convert string dates to datetime objects.
Accepts:
- ISO 8601 format strings with timezone (e.g., "2020-01-01T00:00:00Z")
- ISO 8601 format strings with timezone without colon (e.g., "2020-01-01T12:30:45-0500")
- ISO 8601 format strings with short timezone (e.g., "2020-01-01T12:30:45+05")
- ISO 8601 format strings with single digit timezone (e.g., "2020-01-01T12:30:45-5")
- datetime objects
Returns:
- datetime object
Raises:
- ValueError: If the string cannot be converted to a valid datetime object
"""
if isinstance(v, str):
# Basic validation - must have 'T' and some form of timezone indicator
if not ('T' in v and ('+' in v or 'Z' in v or '-' in v.split('T')[1])):
raise ValueError(
f"Invalid ISO format datetime: {v}. "
f"Must include date, time with 'T' separator, and timezone."
)
try:
# Try to convert using our flexible converter
return convert_iso_string_to_datetime(v)
except ValueError as e:
# Only raise if conversion actually fails
raise ValueError(f"Invalid ISO format datetime: {v}. {str(e)}")
return v
class UpdateIssueCommentParams(RepositoryRef):
"""Parameters for updating an issue comment."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number containing the comment")
comment_id: int = Field(..., description="Comment ID to update")
body: str = Field(..., description="New comment text")
@field_validator('body')
@classmethod
def validate_body(cls, v):
"""Validate that body is not empty."""
if not v.strip():
raise ValueError("body cannot be empty")
return v
class DeleteIssueCommentParams(RepositoryRef):
"""Parameters for deleting an issue comment."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number containing the comment")
comment_id: int = Field(..., description="Comment ID to delete")
class AddIssueLabelsParams(RepositoryRef):
"""Parameters for adding labels to an issue."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number")
labels: List[str] = Field(..., description="Labels to add")
@field_validator('labels')
@classmethod
def validate_labels(cls, v):
"""Validate that labels list is not empty."""
if not v:
raise ValueError("labels list cannot be empty")
return v
class RemoveIssueLabelParams(RepositoryRef):
"""Parameters for removing a label from an issue."""
model_config = ConfigDict(strict=True)
issue_number: int = Field(..., description="Issue number")
label: str = Field(..., description="Label to remove")
@field_validator('label')
@classmethod
def validate_label(cls, v):
"""Validate that label is not empty."""
if not v.strip():
raise ValueError("label cannot be empty")
return v