aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/evergreen/issues.py
diff options
context:
space:
mode:
authorGravatar Matteo Bertucci <[email protected]>2021-04-06 10:05:40 +0200
committerGravatar Matteo Bertucci <[email protected]>2021-04-06 10:05:40 +0200
commit08b1348b33d2d865987f2677a68ba62645db2abe (patch)
tree87d64c36d27625eb8a6134a93d01abeb3263a329 /bot/exts/evergreen/issues.py
parentIssues: limit results to 5 (diff)
Rewrite issue cog
- Switch to a dataclass based communication - Use GitHub headers when querying the repo_regex - Properly handle non-200 return codes - Use @whitelist_override
Diffstat (limited to 'bot/exts/evergreen/issues.py')
-rw-r--r--bot/exts/evergreen/issues.py205
1 files changed, 119 insertions, 86 deletions
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
index afaa7012..dce11678 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -2,12 +2,13 @@ import logging
import random
import re
import typing as t
-from enum import Enum
+from dataclasses import dataclass
import discord
from discord.ext import commands, tasks
from bot.constants import Categories, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
+from bot.utils.decorators import whitelist_override
log = logging.getLogger(__name__)
@@ -17,7 +18,10 @@ BAD_RESPONSE = {
}
REQUEST_HEADERS = dict()
-REPOS_API = "https://api.github.com/orgs/{org}/repos"
+REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos"
+ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}"
+PR_MERGE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
+
if GITHUB_TOKEN := Tokens.github:
REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
@@ -35,11 +39,23 @@ CODE_BLOCK_RE = re.compile(
MAXIMUM_ISSUES = 5
-class FetchIssueErrors(Enum):
- """Errors returned in fetch issues."""
+@dataclass
+class FetchError:
+ """Dataclass representing an error while fetching an issue."""
+
+ return_code: int
+ message: str
+
- value_error = "Numbers not found."
- max_requests = "Max requests hit."
+@dataclass
+class IssueState:
+ """Dataclass representing the state of an issue."""
+
+ repository: str
+ number: int
+ url: str
+ title: str
+ icon_url: str
class Issues(commands.Cog):
@@ -48,19 +64,27 @@ class Issues(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.repos = []
+ self.repo_regex = None
self.get_pydis_repos.start()
@tasks.loop(minutes=30)
async def get_pydis_repos(self) -> None:
- """Get all python-discord repositories on github."""
- async with self.bot.http_session.get(REPOS_API.format(org="python-discord")) as resp:
+ """
+ Get all python-discord repositories on github.
+
+ This task will update a pipe-separated list of repositories in self.repo_regex.
+ """
+ async with self.bot.http_session.get(
+ REPOSITORY_ENDPOINT.format(org="python-discord"),
+ headers=REQUEST_HEADERS
+ ) as resp:
if resp.status == 200:
data = await resp.json()
for repo in data:
self.repos.append(repo["full_name"].split("/")[1])
self.repo_regex = "|".join(self.repos)
else:
- log.debug(f"Failed to get latest Pydis repositories. Status code {resp.status}")
+ log.warning(f"Failed to get latest Pydis repositories. Status code {resp.status}")
@staticmethod
def check_in_block(message: discord.Message, repo_issue: str) -> bool:
@@ -75,70 +99,85 @@ class Issues(commands.Cog):
async def fetch_issues(
self,
- numbers: set,
+ number: int,
repository: str,
user: str
- ) -> t.Union[FetchIssueErrors, str, list]:
- """Retrieve issue(s) from a GitHub repository."""
- links = []
- if not numbers:
- return FetchIssueErrors.value_error
-
- if len(numbers) > MAXIMUM_ISSUES:
- return FetchIssueErrors.max_requests
-
- for number in numbers:
- url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
- merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
- log.trace(f"Querying GH issues API: {url}")
- async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
- json_data = await r.json()
-
- if r.status in BAD_RESPONSE:
- log.warning(f"Received response {r.status} from: {url}")
- return f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}"
-
- # The initial API request is made to the issues API endpoint, which will return information
- # if the issue or PR is present. However, the scope of information returned for PRs differs
- # from issues: if the 'issues' key is present in the response then we can pull the data we
- # need from the initial API call.
- if "issues" in json_data.get("html_url"):
+ ) -> t.Union[IssueState, FetchError]:
+ """
+ Retrieve an issue from a GitHub repository.
+
+ returns IssueState on success, FetchError on failure.
+ """
+ url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number)
+ merge_url = PR_MERGE_ENDPOINT.format(user=user, repository=repository, number=number)
+ log.trace(f"Querying GH issues API: {url}")
+
+ async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
+ json_data = await r.json()
+
+ if r.status == 403:
+ if "X-RateLimit-Remaining" in r.headers and r.headers["X-RateLimit-Remaining"] == "0":
+ log.info(f"Ratelimit reached while fetching {url}")
+ return FetchError(403, "Ratelimit reached, please retry in a few minutes.")
+ return FetchError(403, "Cannot access issue.")
+ elif r.status in (404, 410):
+ return FetchError(r.status, "Issue not found.")
+ elif r.status != 200:
+ return FetchError(r.status, "Error while fetching issue.")
+
+ # The initial API request is made to the issues API endpoint, which will return information
+ # if the issue or PR is present. However, the scope of information returned for PRs differs
+ # from issues: if the 'issues' key is present in the response then we can pull the data we
+ # need from the initial API call.
+ if "issues" in json_data.get("html_url"):
+ if json_data.get("state") == "open":
+ icon_url = Emojis.issue
+ else:
+ icon_url = Emojis.issue_closed
+
+ # If the 'issues' key is not contained in the API response and there is no error code, then
+ # we know that a PR has been requested and a call to the pulls API endpoint is necessary
+ # to get the desired information for the PR.
+ else:
+ log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
+ async with self.bot.http_session.get(merge_url) as m:
if json_data.get("state") == "open":
- icon_url = Emojis.issue
+ icon_url = Emojis.pull_request
+ # When the status is 204 this means that the state of the PR is merged
+ elif m.status == 204:
+ icon_url = Emojis.merge
else:
- icon_url = Emojis.issue_closed
-
- # If the 'issues' key is not contained in the API response and there is no error code, then
- # we know that a PR has been requested and a call to the pulls API endpoint is necessary
- # to get the desired information for the PR.
- else:
- log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
- async with self.bot.http_session.get(merge_url) as m:
- if json_data.get("state") == "open":
- icon_url = Emojis.pull_request
- # When the status is 204 this means that the state of the PR is merged
- elif m.status == 204:
- icon_url = Emojis.merge
- else:
- icon_url = Emojis.pull_request_closed
+ icon_url = Emojis.pull_request_closed
- issue_url = json_data.get("html_url")
- links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url])
+ issue_url = json_data.get("html_url")
- return links
+ return IssueState(repository, number, issue_url, json_data.get('title', ''), icon_url)
@staticmethod
- def get_embed(result: list, user: str = "python-discord", repository: str = "") -> discord.Embed:
- """Get Response Embed."""
- description_list = ["{0} [{1}]({2})".format(*link) for link in result]
+ def format_embed(
+ results: t.List[t.Union[IssueState, FetchError]],
+ user: str,
+ repository: t.Optional[str] = None
+ ) -> discord.Embed:
+ """Take a list of IssueState or FetchError and format a Discord embed for them."""
+ description_list = []
+
+ for result in results:
+ if isinstance(result, IssueState):
+ description_list.append(f"{result.icon_url} [{result.title}]({result.url})")
+ elif isinstance(result, FetchError):
+ description_list.append(f"[{result.return_code}] {result.message}")
+
resp = discord.Embed(
colour=Colours.bright_green,
description='\n'.join(description_list)
)
- resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}")
+ embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}"
+ resp.set_author(name="GitHub", url=embed_url)
return resp
+ @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(aliases=("pr",))
async def issue(
self,
@@ -148,62 +187,56 @@ class Issues(commands.Cog):
user: str = "python-discord"
) -> None:
"""Command to retrieve issue(s) from a GitHub repository."""
- if not(
- ctx.channel.category.id in WHITELISTED_CATEGORIES
- or ctx.channel.id in WHITELISTED_CHANNELS
- ):
- return
-
- result = await self.fetch_issues(set(numbers), repository, user)
-
- if result == FetchIssueErrors.value_error:
- await ctx.invoke(self.bot.get_command('help'), 'issue')
+ # Remove duplicates
+ numbers = set(numbers)
- elif result == FetchIssueErrors.max_requests:
+ if len(numbers) > MAXIMUM_ISSUES:
embed = discord.Embed(
title=random.choice(ERROR_REPLIES),
color=Colours.soft_red,
description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
)
await ctx.send(embed=embed)
+ await ctx.invoke(self.bot.get_command('help'), 'issue')
- elif isinstance(result, list):
- # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
- resp = self.get_embed(result, user, repository)
- await ctx.send(embed=resp)
-
- elif isinstance(result, str):
- await ctx.send(result)
+ results = [await self.fetch_issues(number, repository, user) for number in numbers]
+ await ctx.send(embed=self.format_embed(results))
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Command to retrieve issue(s) from a GitHub repository using automatic linking if matching <repo>#<issue>."""
- message_repo_issue_map = re.findall(fr"({self.repo_regex})#(\d+)", message.content)
+ if not self.repo_regex:
+ log.warning("repo_regex isn't ready, cannot look for issues.")
+ return
+
+ # `issues` will hold a list of two element tuples `(repository, issue_number)`
+ issues = re.findall(fr"({self.repo_regex})#(\d+)", message.content)
links = []
- if message_repo_issue_map:
+ if issues:
+ log.trace(f"Found {issues = }")
# Remove duplicates
- message_repo_issue_map = set(message_repo_issue_map)
+ issues = set(issues)
- if len(message_repo_issue_map) > MAXIMUM_ISSUES:
+ if len(issues) > MAXIMUM_ISSUES:
embed = discord.Embed(
title=random.choice(ERROR_REPLIES),
color=Colours.soft_red,
description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
)
- await message.channel.send(embed=embed)
+ await message.channel.send(embed=embed).delete(delay=5)
return
- for repo_issue in message_repo_issue_map:
+ for repo_issue in issues:
if not self.check_in_block(message, " ".join(repo_issue)):
- result = await self.fetch_issues({repo_issue[1]}, repo_issue[0], "python-discord")
- if isinstance(result, list):
- links.extend(result)
+ result = await self.fetch_issues(repo_issue[1], repo_issue[0], "python-discord")
+ if isinstance(result, IssueState):
+ links.append(result)
if not links:
return
- resp = self.get_embed(links, "python-discord")
+ resp = self.format_embed(links, "python-discord")
await message.channel.send(embed=resp)