Source code for smarter.apps.mcpserver.github.issues

"""
GitHub issues of The Smarter Project's repositories: searched, opened and commented on.

- :func:`search_issues` asks GitHub's issue search, ``GET /search/issues``, live, rather than
  the index: issues change by the minute, and an LLM that is about to open an issue must see
  one that was opened a minute ago. It searches one repository with ``repo:``, or all of them
  with ``org:``, and drops the results of the organization's other repositories. Issues are
  public, so the result does not depend on who asks. Without the caller's classic token the
  search is anonymous, and GitHub allows anonymous clients 10 searches a minute for each IP
  address, which the platform's users share. A token raises that to 30 for its owner.
- :func:`read_issue` reads an issue and its latest comments, with ``GET /repos/{owner}/{repo}/issues/{number}``
  and its ``/comments``, so that an LLM can see what an issue already says before it adds to it.
- :func:`create_issue` opens an issue, optionally with labels, with ``POST /repos/{owner}/{repo}/issues``.
- :func:`close_issue` closes an issue that a merged pull request resolved, with a comment that says
  why, after GitHub confirms that the pull request was merged.
- :func:`comment_on_issue` adds a comment to an existing issue, with
  ``POST /repos/{owner}/{repo}/issues/{number}/comments``.

:func:`create_issue` and :func:`comment_on_issue` write as the caller, with the caller's own
classic personal access token, so the issue or comment is the caller's, and GitHub decides
what the caller may do. Both append a footer that says where the text came from. Never cache
them: every call writes. Neither writes a credential: see
:func:`~smarter.apps.mcpserver.github.client.refuse_secrets`. See
:mod:`smarter.apps.mcpserver.github.client` for how the token is checked and kept secret, and how
GitHub's errors are explained.

.. note::

    **Experimental.** The MCPServer was designed and coded by Claude Code (Anthropic's
    Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
    be documented.
"""

from typing import Literal, Optional

import requests
from pydantic import BaseModel, ConfigDict, Field, JsonValue, SecretStr, ValidationError

from smarter.apps.mcpserver.exceptions import SmarterMCPServerToolError
from smarter.apps.mcpserver.repositories import GITHUB_API_URL, ProjectRepository
from smarter.lib import logging
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.typing import SmarterDictType

from .client import (
    explain_failure,
    github_request,
    optional_token,
    refuse_secrets,
    validate_token,
)

logger = logging.getSmarterLogger(__name__, any_switches=[SmarterWaffleSwitches.MCPSERVER_LOGGING])
logger_prefix = logging.SmarterFormattedText.text(__name__)


IssueState = Literal["open", "closed", "all"]
MAX_EXCERPT_LENGTH = 300
"""The length of each issue's body excerpt in the result."""


[docs] class GitHubIssueHit(BaseModel): """ One issue that GitHub's search found, with the fields that the tool reports. GitHub's response has many more fields, which are ignored. """ model_config = ConfigDict(frozen=True, extra="ignore") number: int = Field(description="The issue's number in its repository.") title: str = Field(description="The issue's title.") state: str = Field(description="open or closed.") html_url: str = Field(description="The issue's url on github.com.") repository_url: str = Field(description="The api url of the issue's repository.") comments: int = Field(default=0, description="The number of comments.") updated_at: str = Field(default="", description="When the issue last changed, in ISO 8601.") body: Optional[str] = Field(default=None, description="The issue's body, in Markdown.") @property def repository(self) -> str: """The repository's full name, e.g. ``smarter-sh/smarter``.""" return self.repository_url.removeprefix(f"{GITHUB_API_URL}/repos/") @property def excerpt(self) -> str: """The start of the issue's body, on one line.""" text = " ".join((self.body or "").split()) return text if len(text) <= MAX_EXCERPT_LENGTH else text[: MAX_EXCERPT_LENGTH - 3] + "..."
class GitHubIssueSearchResult(BaseModel): """GitHub's answer to an issue search, with the fields that the tool reads.""" model_config = ConfigDict(frozen=True, extra="ignore") total_count: int = 0 items: list[GitHubIssueHit] = Field(default_factory=list) def search_query(query: str, repositories: tuple[ProjectRepository, ...], one: bool, state: IssueState) -> str: """ Return GitHub's search query: the keywords, issues only, the repositories and the state. :param query: The keywords. :param repositories: The repositories to search. :param one: Whether to search only the first repository, with ``repo:``, rather than the organization. :param state: ``open``, ``closed`` or ``all``. :returns: The query, e.g. ``guardrail apply is:issue repo:smarter-sh/smarter state:open``. """ where = f"repo:{repositories[0].full_name}" if one else f"org:{repositories[0].owner}" parts = [query.strip(), "is:issue", where] if state != "all": parts.append(f"state:{state}") return " ".join(parts)
[docs] def search_issues( repositories: tuple[ProjectRepository, ...], query: str, one: bool, state: IssueState, limit: int, token: Optional[SecretStr], ) -> list[GitHubIssueHit]: """ Search the issues of the repositories, best match first. :param repositories: The repositories to search. They have the same owner. :param query: The keywords. :param one: Whether to search only the first repository, rather than all of them. :param state: ``open``, ``closed`` or ``all``. :param limit: The maximum number of issues. :param token: The caller's GitHub token, or None. Only a classic personal access token is sent: otherwise the search is anonymous. :returns: The issues of the repositories. :raises SmarterMCPServerToolError: If GitHub could not be reached, or refused the search, e.g. its rate limit was reached. """ value = optional_token(token) names = {repository.full_name for repository in repositories} # the organization's other repositories are dropped, so ask for more than the limit. per_page = limit if one else min(100, limit * 3) response = github_request( "GET", "/search/issues", value, params={"q": search_query(query, repositories, one, state), "per_page": str(per_page)}, ) if response.status_code != 200: logger.info("%s.search_issues() GitHub did not search: HTTP %s", logger_prefix, response.status_code) raise SmarterMCPServerToolError(explain_failure(response, repositories[0], "search the issues")) try: result = GitHubIssueSearchResult.model_validate(response.json()) except (ValueError, ValidationError) as e: logger.warning("%s.search_issues() GitHub's response is not a search result: %s", logger_prefix, e) raise SmarterMCPServerToolError("GitHub's search result could not be read. Try again later.") from e return [hit for hit in result.items if hit.repository in names][:limit]
COMMENTS_PER_PAGE = 100 """The most comments that GitHub returns in one page, and that :func:`read_issue` reads.""" class GitHubUser(BaseModel): """A GitHub account, with the field that the tools report.""" model_config = ConfigDict(frozen=True, extra="ignore") login: str = Field(description="The account's login.") class GitHubLabel(BaseModel): """A label of an issue, with the field that the tools report.""" model_config = ConfigDict(frozen=True, extra="ignore") name: str = Field(description="The label's name.")
[docs] class GitHubIssueComment(BaseModel): """ A comment on an issue, with the fields that read_issue reports. GitHub's response has many more fields, which are ignored. """ model_config = ConfigDict(frozen=True, extra="ignore") user: Optional[GitHubUser] = Field(default=None, description="The comment's author.") created_at: str = Field(default="", description="When the comment was written, in ISO 8601.") html_url: str = Field(default="", description="The comment's url on github.com.") body: Optional[str] = Field(default=None, description="The comment, in Markdown.")
[docs] class GitHubIssueDetail(BaseModel): """ One issue, as ``GET /repos/{owner}/{repo}/issues/{number}`` returns it, with the fields that read_issue reports. GitHub's response has many more fields, which are ignored. GitHub returns a pull request from this endpoint too, with a ``pull_request`` field. """ model_config = ConfigDict(frozen=True, extra="ignore") number: int = Field(description="The issue's number in its repository.") title: str = Field(description="The issue's title.") state: str = Field(description="open or closed.") state_reason: Optional[str] = Field(default=None, description="Why it was closed, e.g. completed or not_planned.") html_url: str = Field(description="The issue's url on github.com.") user: Optional[GitHubUser] = Field(default=None, description="Who opened the issue.") labels: list[GitHubLabel] = Field(default_factory=list, description="The issue's labels.") comments: int = Field(default=0, description="The number of comments.") created_at: str = Field(default="", description="When the issue was opened, in ISO 8601.") updated_at: str = Field(default="", description="When the issue last changed, in ISO 8601.") closed_at: Optional[str] = Field(default=None, description="When the issue was closed, in ISO 8601.") body: Optional[str] = Field(default=None, description="The issue's body, in Markdown.") pull_request: Optional[JsonValue] = Field(default=None, description="Present if it is a pull request.")
[docs] class GitHubIssueThread(BaseModel): """An issue and its latest comments, oldest first.""" model_config = ConfigDict(frozen=True) issue: GitHubIssueDetail comments: list[GitHubIssueComment] = Field(default_factory=list)
[docs] def read_issue(repository: ProjectRepository, number: int, token: Optional[SecretStr]) -> GitHubIssueThread: """ Read an issue of a repository, and its latest :data:`COMMENTS_PER_PAGE` comments. Issues are public, so the result does not depend on who asks. Without the caller's classic token the request is anonymous, at GitHub's anonymous rate limit. :param repository: The repository. :param number: The issue's number. :param token: The caller's GitHub token, or None. Only a classic personal access token is sent. :returns: The issue and its latest comments, oldest first. :raises SmarterMCPServerToolError: If GitHub could not be reached, the issue was not found, or GitHub's response could not be read. """ value = optional_token(token) path = f"/repos/{repository.full_name}/issues/{number}" not_found = f"Issue #{number} was not found in {repository.full_name}. Find it with search_issues." response = github_request("GET", path, value) if response.status_code != 200: logger.info( "%s.read_issue() GitHub did not return %s#%s: HTTP %s", logger_prefix, repository.full_name, number, response.status_code, ) raise SmarterMCPServerToolError( explain_failure(response, repository, f"read issue #{number}", not_found=not_found) ) try: issue = GitHubIssueDetail.model_validate(response.json()) except (ValueError, ValidationError) as e: logger.warning("%s.read_issue() GitHub's response is not an issue: %s", logger_prefix, e) raise SmarterMCPServerToolError(f"GitHub's issue #{number} could not be read. Try again later.") from e if not issue.comments: return GitHubIssueThread(issue=issue) # the last page holds the latest comments, which say what is already known. last_page = (issue.comments - 1) // COMMENTS_PER_PAGE + 1 response = github_request( "GET", f"{path}/comments", value, params={"per_page": str(COMMENTS_PER_PAGE), "page": str(last_page)} ) if response.status_code != 200: raise SmarterMCPServerToolError( explain_failure(response, repository, f"read the comments of issue #{number}", not_found=not_found) ) try: comments = [GitHubIssueComment.model_validate(comment) for comment in response.json()] except (ValueError, TypeError, ValidationError) as e: logger.warning("%s.read_issue() GitHub's response is not a list of comments: %s", logger_prefix, e) raise SmarterMCPServerToolError(f"The comments of issue #{number} could not be read. Try again later.") from e return GitHubIssueThread(issue=issue, comments=comments)
MAX_TITLE_LENGTH = 256 """GitHub's maximum length of an issue's title.""" MAX_BODY_LENGTH = 20000 """The maximum length of an issue's or comment's body, well below GitHub's limit of 65536 characters.""" ISSUE_FOOTER = "\n\n---\n_Opened with the Smarter Project MCP server._" """Appended to every issue's body, so that maintainers can tell where it came from.""" COMMENT_FOOTER = "\n\n---\n_Written with the Smarter Project MCP server._" """Appended to every comment's body."""
[docs] class GitHubIssue(BaseModel): """ An issue that GitHub created, with the fields of its response that the tool reports. GitHub's response has many more fields, which are ignored. """ model_config = ConfigDict(frozen=True, extra="ignore") number: int = Field(description="The issue's number in its repository.") title: str = Field(description="The issue's title.") html_url: str = Field(description="The issue's url on github.com.") author: Optional[str] = Field(default=None, description="The GitHub login of the token's owner.")
[docs] class GitHubComment(BaseModel): """ A comment that GitHub created, with the fields of its response that the tool reports. GitHub's response has many more fields, which are ignored. """ model_config = ConfigDict(frozen=True, extra="ignore") id: int = Field(description="The comment's id.") html_url: str = Field(description="The comment's url on github.com.") author: Optional[str] = Field(default=None, description="The GitHub login of the token's owner.")
def created(response: requests.Response, repository: ProjectRepository, what: str) -> SmarterDictType: """ Return GitHub's description of what it created, with its author's login as ``author``. :param response: GitHub's ``201 Created`` response. :param repository: The repository. :param what: What was created, e.g. ``an issue``, for the error message. :returns: The response's JSON object. :raises SmarterMCPServerToolError: If the response is not a JSON object. """ try: data = response.json() return {**data, "author": (data.get("user") or {}).get("login")} except (ValueError, AttributeError) as e: logger.warning("%s.created() GitHub's response is not JSON: %s", logger_prefix, e) raise SmarterMCPServerToolError( f"GitHub created {what} in {repository.full_name}, but its response could not be read. " f"See {repository.github_url}/issues." ) from e
[docs] def create_issue( repository: ProjectRepository, title: str, body: str, token: Optional[SecretStr], labels: tuple[str, ...] = (), ) -> GitHubIssue: """ Open an issue in a repository of The Smarter Project, as the owner of a GitHub token. :param repository: The repository. :param title: The issue's title. :param body: The issue's body, in Markdown. :data:`ISSUE_FOOTER` is appended. :param token: The caller's classic personal access token. :param labels: The issue's labels, e.g. ``bug``. GitHub creates a label that the repository does not have, and silently drops the labels of a caller who cannot push to the repository. :returns: The new issue. :raises SmarterMCPServerToolError: If the token is not a classic personal access token, the title or body holds a credential, or GitHub could not be reached, or did not create the issue. """ value = validate_token(token) refuse_secrets(value, title, body, *labels) data: SmarterDictType = {"title": title, "body": body + ISSUE_FOOTER} if labels: data["labels"] = list(labels) response = github_request("POST", f"/repos/{repository.full_name}/issues", value, json=data) if response.status_code != 201: logger.info( "%s.create_issue() GitHub did not open an issue in %s: HTTP %s", logger_prefix, repository.full_name, response.status_code, ) raise SmarterMCPServerToolError(explain_failure(response, repository, "open the issue")) try: issue = GitHubIssue.model_validate(created(response, repository, "an issue")) except ValidationError as e: logger.warning("%s.create_issue() GitHub's response is not an issue: %s", logger_prefix, e) raise SmarterMCPServerToolError( f"GitHub opened an issue in {repository.full_name}, but its response could not be read. " f"See {repository.github_url}/issues." ) from e logger.info("%s.create_issue() opened %s", logger_prefix, issue.html_url) return issue
[docs] def comment_on_issue( repository: ProjectRepository, number: int, body: str, token: Optional[SecretStr] ) -> GitHubComment: """ Add a comment to an issue of a repository of The Smarter Project, as the owner of a GitHub token. :param repository: The repository. :param number: The issue's number. :param body: The comment, in Markdown. :data:`COMMENT_FOOTER` is appended. :param token: The caller's classic personal access token. :returns: The new comment. :raises SmarterMCPServerToolError: If the token is not a classic personal access token, the body holds a credential, or GitHub could not be reached, or did not create the comment, e.g. the issue is locked. """ value = validate_token(token) refuse_secrets(value, body) response = github_request( "POST", f"/repos/{repository.full_name}/issues/{number}/comments", value, json={"body": body + COMMENT_FOOTER}, ) if response.status_code != 201: logger.info( "%s.comment_on_issue() GitHub did not comment on %s#%s: HTTP %s", logger_prefix, repository.full_name, number, response.status_code, ) raise SmarterMCPServerToolError( explain_failure( response, repository, f"comment on issue #{number}", not_found=f"Issue #{number} was not found in {repository.full_name}. Find it with search_issues.", ) ) try: comment = GitHubComment.model_validate(created(response, repository, "a comment")) except ValidationError as e: logger.warning("%s.comment_on_issue() GitHub's response is not a comment: %s", logger_prefix, e) raise SmarterMCPServerToolError( f"GitHub added a comment to {repository.full_name}#{number}, but its response could not be read. " f"See {repository.github_url}/issues/{number}." ) from e logger.info("%s.comment_on_issue() commented %s", logger_prefix, comment.html_url) return comment
[docs] class GitHubPullRequestState(BaseModel): """ A pull request, as ``GET /repos/{owner}/{repo}/pulls/{number}`` returns it, with the fields that close_issue reads. GitHub's response has many more fields, which are ignored. """ model_config = ConfigDict(frozen=True, extra="ignore") number: int = Field(description="The pull request's number.") html_url: str = Field(description="The pull request's url on github.com.") merged: bool = Field(default=False, description="Whether it was merged.") merged_at: Optional[str] = Field(default=None, description="When it was merged, in ISO 8601.")
[docs] class GitHubIssueClosed(BaseModel): """An issue that close_issue closed, with the comment that says why.""" model_config = ConfigDict(frozen=True) number: int = Field(description="The issue's number.") html_url: str = Field(description="The issue's url on github.com.") comment_url: str = Field(description="The url of the comment that says why it was closed.") pull_request_url: str = Field(description="The url of the merged pull request that resolved it.") author: Optional[str] = Field(default=None, description="The GitHub login of the token's owner.")
[docs] def close_issue( repository: ProjectRepository, number: int, pull_request_number: int, comment: str, token: Optional[SecretStr], ) -> GitHubIssueClosed: """ Close an issue that a merged pull request resolved, with a comment that says why, as the owner of a GitHub token. The issue is closed only if GitHub says that the pull request was merged, so that an LLM cannot close an issue on its own judgment alone, nor because a log or an issue told it to. The comment is added first, and the issue is then closed as ``completed``. :param repository: The repository of the issue and the pull request. :param number: The issue's number. :param pull_request_number: The number of the merged pull request that resolved the issue. :param comment: Why the issue is resolved, in Markdown. :data:`COMMENT_FOOTER` is appended. :param token: The caller's classic personal access token. :returns: The closed issue. :raises SmarterMCPServerToolError: If the token is not a classic personal access token, the comment holds a credential, the pull request was not found or not merged, or GitHub could not be reached, or refused the comment or the closing. """ value = validate_token(token) refuse_secrets(value, comment) if pull_request_number == number: raise SmarterMCPServerToolError("An issue cannot be resolved by itself: give the merged pull request's number.") response = github_request("GET", f"/repos/{repository.full_name}/pulls/{pull_request_number}", value) if response.status_code != 200: raise SmarterMCPServerToolError( explain_failure( response, repository, f"read pull request #{pull_request_number}", not_found=f"Pull request #{pull_request_number} was not found in {repository.full_name}.", ) ) try: pull_request = GitHubPullRequestState.model_validate(response.json()) except (ValueError, ValidationError) as e: logger.warning("%s.close_issue() GitHub's response is not a pull request: %s", logger_prefix, e) raise SmarterMCPServerToolError( f"Pull request #{pull_request_number} could not be read. Try again later." ) from e if not pull_request.merged: raise SmarterMCPServerToolError( f"Pull request #{pull_request_number} is not merged, so issue #{number} was not closed. Close an issue " "only when the merged pull request that resolved it is known." ) posted = comment_on_issue(repository, number, comment, token) response = github_request( "PATCH", f"/repos/{repository.full_name}/issues/{number}", value, json={"state": "closed", "state_reason": "completed"}, ) if response.status_code != 200: logger.info( "%s.close_issue() GitHub did not close %s#%s: HTTP %s", logger_prefix, repository.full_name, number, response.status_code, ) reason = explain_failure(response, repository, f"close issue #{number}") raise SmarterMCPServerToolError( f"The comment was added, at {posted.html_url}, but the issue was not closed: {reason} " "Do not add the comment again." ) logger.info( "%s.close_issue() closed %s#%s, resolved by #%s", logger_prefix, repository.full_name, number, pull_request_number, ) return GitHubIssueClosed( number=number, html_url=f"{repository.github_url}/issues/{number}", comment_url=posted.html_url, pull_request_url=pull_request.html_url, author=posted.author, )
__all__ = [ "GitHubComment", "GitHubIssue", "GitHubIssueClosed", "GitHubIssueComment", "GitHubIssueDetail", "GitHubIssueHit", "GitHubIssueThread", "GitHubPullRequestState", "IssueState", "close_issue", "comment_on_issue", "create_issue", "read_issue", "search_issues", ]