223 lines
8.7 KiB
Python
223 lines
8.7 KiB
Python
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"<!--[\s\S]*?-->"
|
|
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
|