import re from datetime import datetime from typing import Any from aiohttp import ClientResponse from discord import ButtonStyle, Color, Embed, Emoji, Interaction, Message, NotFound, PartialEmoji, ui from pydantic import BaseModel, ConfigDict, field_validator from redbot.core.bot import commands from ..constants import TYPES, TYPES_LIST, convert_name from .repository import Repository class Issue(BaseModel): """ Model for provider issues. Used as an abstraction layer over the GitHub, GitLab, and Forgejo APIs, to simplify Discord-sided integration. "Issue" here refers to anything that uses the normal issue numbering scheme, so this includes Pull Requests and (GitHub) Discussions. Attributes: repository (Repository): The repository the issue belongs to. number (int): The issue number. author (str): The author of the issue. author_avatar (str): The author's avatar URL. author_url (str): The author's profile URL. link (str): A link to the issue. title (str): The title of the issue. body (str): The contents of the issue. type (str): The type of issue. Can be `issue`, `pull_request`, or `discussion`. draft (bool): Whether the issue is a draft. This will usually only apply to pull requests. creation_date (datetime): The date the issue was created on. milestone (str | None): The milestone the issue is associated with, if applicable. milestone_url (str | None): The URL to the milestone, if applicable. merge_date (datetime | None): The date the issue was merged, if applicable. This will usually only apply to pull requests. response (aiohttp.ClientResponse | None): The raw response from the provider API, if available. has_short_embed (bool): Whether the currently present message for this issue has a short embed. Defaults to False, please don't set this manually. message (discord.Message | None): The message object for the issue embed, if available. Properties: color (discord.Color): The color for the embed based on the issue type. emoji (discord.PartialEmoji | discord.Emoji): The emoji for the issue based on its type. markdown_link (str): A Markdown-formatted link to the issue. prefixed_number (str): The issue number prefixed with a `#`, or a `!` if it's a pull request and is from GitLab. pretty_title (str): The title with the some extra metadata. unix_timestamp (int): The creation date as a Unix timestamp. """ model_config = ConfigDict(arbitrary_types_allowed=True) repository: Repository number: int author: str author_avatar: str author_url: str link: str title: str body: str type: str labels: list[str] = [] draft: bool = False creation_date: datetime milestone: str | None = None milestone_url: str | None = None merge_date: datetime | None = None response: ClientResponse | None = None _has_short_embed: bool = False _message: Message | None = None @field_validator("type") @classmethod def _validate_type(cls, value: Any) -> Any: if value not in TYPES_LIST: raise ValueError("Issue type '%s' is not supported." % value) return value @property def cleaned_body(self) -> str: pattern = r"" return re.sub(pattern, "", self.body) @property def color(self) -> Color: """Get the color for the embed based on the issue type.""" for t in TYPES: if t["name"] == self.type: return t["color"] return Color.from_str("#000000") # Default to black if no match found @property def emoji(self) -> PartialEmoji | Emoji: """ Return the emoji for the issue based on its type. This is used for Discord embeds. """ name = "" for t in TYPES: if t["name"] == self.type: name: str = t["name"] emoji_name = convert_name(name) for emoji in self.repository.cog.application_emojis: if emoji.name == emoji_name: return emoji return PartialEmoji.from_str("❓") @property def footer_text(self) -> str: return f"{self.repository.owner}/{self.repository.name} • {'Merged' if self.merge_date else 'Created'}" @property def markdown_link(self) -> str: return f"[{self.pretty_title}]({self.link})" @property def markdown_milestone_link(self) -> str: if not self.milestone: raise ValueError("Issue does not have a milestone.") if self.milestone_url: return f"[{self.milestone}]({self.milestone_url})" return self.milestone @property def prefixed_number(self) -> str: if self.repository.provider.service == "gitlab" and "pull_request" in self.type: return f"!{self.number}" return f"#{self.number}" @property def pretty_title(self) -> str: """Return the title with the some extra metadata.""" return f"{str(self.emoji)} {self.title} ({self.prefixed_number})" @property def timestamp(self) -> datetime: return self.merge_date if self.merge_date else self.creation_date @property def unix_timestamp(self) -> int: return int(self.timestamp.timestamp()) async def send(self, ctx: commands.Context | Message, reply: bool = False, short: bool = False, **kwargs: Any) -> Message: """Send an embed for this Issue. Args: ctx (commands.Context | discord.Message): The context to send the embed in. reply (bool): Whether to reply to the message. Defaults to `False`. short (bool): Whether to send a short embed. Defaults to `False`. **kwargs (Any): Additional keyword arguments to pass to `discord.Messageable.send`. """ if reply is True: msg = await ctx.reply(embed=self.short_embed() if short else self.embed(), view=_IssueView(self), mention_author=False, **kwargs) else: msg = await ctx.channel.send(embed=self.short_embed() if short else self.embed(), view=_IssueView(self), **kwargs) self._message = msg return msg def embed(self) -> Embed: """Create an embed for this Issue.""" embed = self.short_embed() body = self.cleaned_body if len(body) > 4096: body = body[:4093] + "..." embed.description = body or "No description provided." if len(self.labels) > 0: formatted_labels: list[str] = [] for label in self.labels: formatted_labels.extend((f"* {label}",)) embed.add_field(name=f"Labels [{len(self.labels)}]", value="\n".join(formatted_labels), inline=True) if self.milestone: embed.add_field(name="Milestone", value=self.markdown_milestone_link, inline=True) return embed def short_embed(self) -> Embed: """Create a short embed for this Issue.""" embed = Embed( url=self.link, title=self.pretty_title, color=self.color, ) embed.set_author( name=self.author, url=self.author_url, icon_url=self.author_avatar, ) embed.set_footer(text=self.footer_text, icon_url=self.repository.provider.favicon_url) embed.timestamp = self.timestamp return embed class _IssueView(ui.View): def __init__(self, issue: Issue, timeout: int = 240) -> None: super().__init__(timeout=timeout) self.issue = issue self.timeout = timeout async def on_timeout(self) -> None: if self.issue._message is not None: # noqa: SLF001 try: await self.issue._message.edit(view=None) # noqa: SLF001 except NotFound: pass @ui.button(emoji="🗑️", style=ButtonStyle.gray) async def delete_message(self, interaction: Interaction, button: ui.Button) -> None: await interaction.response.defer() if interaction.message is not None: try: await interaction.message.delete() except NotFound: pass @ui.button(label="Switch View", style=ButtonStyle.primary) async def switch_message_embed(self, interaction: Interaction, button: ui.Button) -> None: await interaction.response.defer() if interaction.message is not None: self.issue._has_short_embed = not self.issue._has_short_embed # noqa: SLF001 try: await interaction.message.edit(embed=self.issue.short_embed() if self.issue._has_short_embed else self.issue.embed()) # noqa: SLF001 except NotFound: pass