#!/usr/bin/env python3
#
# issues.py
"""
Roles and nodes for GitHub issues and Pull Requests.
"""
#
# Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# Based on pyspecific.py from the Python documentation.
# Copyright 2008-2014 by Georg Brandl.
# Licensed under the PSF License 2.0
#
# Parts of the docstrings based on https://docutils.sourceforge.io/docs/howto/rst-roles.html
#
# stdlib
import warnings
import xml.sax.saxutils
from typing import Any, Dict, List, Optional, Tuple, Union
# 3rd party
import requests # nodep
from apeye.url import URL
from bs4 import BeautifulSoup # type: ignore[import]
from docutils import nodes
from docutils.nodes import system_message
from docutils.parsers.rst.states import Inliner
from sphinx.util.nodes import split_explicit_title
from sphinx.writers.html import HTMLTranslator
from sphinx.writers.latex import LaTeXTranslator
# this package
from sphinx_toolbox.cache import cache
from sphinx_toolbox.utils import make_github_url
__all__ = (
"IssueNode",
"IssueNodeWithName",
"issue_role",
"pull_role",
"visit_issue_node",
"depart_issue_node",
"get_issue_title",
)
[docs]class IssueNode(nodes.reference):
"""
Docutils Node to represent a link to a GitHub *Issue* or *Pull Request*.
:param issue_number: The number of the issue or pull request.
:param refuri: The URL of the issue / pull request on GitHub.
"""
has_tooltip: bool
issue_number: int
issue_url: str
def __init__(
self,
issue_number: Union[str, int],
refuri: Union[str, URL],
**kwargs,
):
self.has_tooltip = False
self.issue_number = int(issue_number)
self.issue_url = str(refuri)
source = f"#{issue_number}"
super().__init__(source, source, refuri=self.issue_url)
@property
def _copy_kwargs(self): # pragma: no cover # noqa: MAN002
return {"issue_number": self.issue_number, "refuri": self.issue_url}
def copy(self) -> "IssueNode": # pragma: no cover
"""
Return a copy of the :class:`sphinx_toolbox.github.issues.IssueNode`.
"""
# This was required to stop some breakage, but it doesn't seem to run during the tests.
obj = self.__class__(**self._copy_kwargs)
obj.document = self.document
obj.has_tooltip = self.has_tooltip
obj.line = self.line
return obj
[docs]class IssueNodeWithName(IssueNode):
"""
Docutils Node to represent a link to a GitHub *Issue* or *Pull Request*, with the repository name shown.
.. versionadded:: 2.4.0
:param repo_name: The full name of the repository, in the form ``owner/name``.
:param issue_number: The number of the issue or pull request.
:param refuri: The URL of the issue / pull request on GitHub.
"""
repo_name: str
def __init__(
self,
repo_name: str,
issue_number: Union[str, int],
refuri: Union[str, URL],
**kwargs,
):
self.has_tooltip = False
self.issue_number = int(issue_number)
self.issue_url = str(refuri)
self.repo_name = str(repo_name)
source = f"{repo_name}#{issue_number}"
nodes.reference.__init__(self, source, source, refuri=self.issue_url)
@property
def _copy_kwargs(self) -> Dict[str, Any]: # pragma: no cover
return {"repo_name": self.repo_name, "issue_number": self.issue_number, "refuri": self.issue_url}
[docs]def issue_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: Inliner,
options: Dict[str, Any] = {},
content: List[str] = []
) -> Tuple[List[IssueNode], List[system_message]]:
"""
Adds a link to the given issue on GitHub.
:param name: The local name of the interpreted role, the role name actually used in the document.
:param rawtext: A string containing the entire interpreted text input, including the role and markup.
:param text: The interpreted text content.
:param lineno: The line number where the interpreted text begins.
:param inliner: The :class:`docutils.parsers.rst.states.Inliner` object that called :func:`~.issue_role`.
It contains the several attributes useful for error reporting and document tree access.
:param options: A dictionary of directive options for customization (from the ``role`` directive),
to be interpreted by the function.
Used for additional attributes for the generated elements and other functionality.
:param content: A list of strings, the directive content for customization (from the ``role`` directive).
To be interpreted by the function.
:return: A list containing the created node, and a list containing any messages generated during the function.
.. latex:clearpage::
"""
has_t, issue_number, repository = split_explicit_title(text)
issue_number = nodes.unescape(issue_number)
messages: List[system_message] = []
refnode: IssueNode
if has_t:
repository_parts = nodes.unescape(repository).split('/')
if len(repository_parts) != 2:
warning_message = inliner.document.reporter.warning(
f"Invalid repository '{repository}' for issue #{issue_number}.",
)
messages.append(warning_message)
else:
refnode = IssueNodeWithName(
repo_name=repository,
issue_number=issue_number,
refuri=make_github_url(*repository_parts) / "issues" / str(int(issue_number)),
)
return [refnode], messages
issues_url = inliner.document.settings.env.app.config.github_issues_url
refnode = IssueNode(issue_number=issue_number, refuri=issues_url / str(int(issue_number)))
return [refnode], messages
[docs]def pull_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: Inliner,
options: Dict[str, Any] = {},
content: List[str] = []
) -> Tuple[List[IssueNode], List[system_message]]:
"""
Adds a link to the given pulll request on GitHub.
:param name: The local name of the interpreted role, the role name actually used in the document.
:param rawtext: A string containing the entire interpreted text input, including the role and markup.
:param text: The interpreted text content.
:param lineno: The line number where the interpreted text begins.
:param inliner: The :class:`docutils.parsers.rst.states.Inliner` object that called :func:`~.pull_role`.
It contains the several attributes useful for error reporting and document tree access.
:param options: A dictionary of directive options for customization (from the ``role`` directive),
to be interpreted by the function.
Used for additional attributes for the generated elements and other functionality.
:param content: A list of strings, the directive content for customization (from the ``role`` directive).
To be interpreted by the function.
:return: A list containing the created node, and a list containing any messages generated during the function.
"""
has_t, issue_number, repository = split_explicit_title(text)
issue_number = nodes.unescape(issue_number)
messages: List[system_message] = []
refnode: IssueNode
if has_t:
repository_parts = nodes.unescape(repository).split('/')
if len(repository_parts) != 2:
warning_message = inliner.document.reporter.warning(
f"Invalid repository '{repository}' for pull request #{issue_number}."
)
messages.append(warning_message)
else:
refnode = IssueNodeWithName(
repo_name=repository,
issue_number=issue_number,
refuri=make_github_url(*repository_parts) / "pull" / str(int(issue_number)),
)
return [refnode], messages
pull_url = inliner.document.settings.env.app.config.github_pull_url
refnode = IssueNode(issue_number=issue_number, refuri=pull_url / str(int(issue_number)))
return [refnode], messages
[docs]def visit_issue_node(translator: HTMLTranslator, node: IssueNode) -> None:
"""
Visit an :class:`~.IssueNode`.
If the node points to a valid issue / pull request,
add a tooltip giving the title of the issue / pull request and a hyperlink to the page on GitHub.
:param translator:
:param node: The node being visited.
"""
issue_title = get_issue_title(node.issue_url)
if issue_title:
node.has_tooltip = True
translator.body.append(f'<abbr title="{issue_title}">')
translator.visit_reference(node)
else:
warnings.warn(f"Issue/Pull Request #{node.issue_number} not found.")
[docs]def depart_issue_node(translator: HTMLTranslator, node: IssueNode) -> None:
"""
Depart an :class:`~.IssueNode`.
:param translator:
:param node: The node being visited.
"""
if node.has_tooltip:
translator.depart_reference(node)
translator.body.append("</abbr>")
def _visit_issue_node_latex(translator: LaTeXTranslator, node: IssueNode) -> None:
"""
Visit an :class:`~.IssueNode`.
If the node points to a valid issue / pull request,
add a tooltip giving the title of the issue / pull request and a hyperlink to the page on GitHub.
:param translator:
:param node: The node being visited.
"""
node.children = node.children[:1]
translator.visit_reference(node)
def _depart_issue_node_latex(translator: LaTeXTranslator, node: IssueNode) -> None:
"""
Depart an :class:`~.IssueNode`.
:param translator:
:param node: The node being visited.
"""
translator.depart_reference(node)
[docs]def get_issue_title(issue_url: str) -> Optional[str]:
"""
Returns the title of the issue with the given url,
or :py:obj:`None` if the issue isn't found.
:param issue_url:
""" # noqa: D400
try:
r = cache.session.get(issue_url, timeout=30)
except requests.exceptions.RequestException:
return None
if r.status_code == 200:
soup = BeautifulSoup(r.content, "html5lib")
try:
content = soup.find_all("span", attrs={"class": "js-issue-title"})[0].text
except IndexError:
# As of 13 Jan 2023 GitHub seems to use a bidirectional text tag instead
content = soup.find_all("bdi", attrs={"class": "js-issue-title"})[0].text
content = xml.sax.saxutils.escape(content).replace('"', """)
return content.strip()
return None