aboutsummaryrefslogtreecommitdiffstats
path: root/bot
diff options
context:
space:
mode:
Diffstat (limited to 'bot')
-rw-r--r--bot/constants.py36
-rw-r--r--bot/exts/easter/easter_riddle.py6
-rw-r--r--bot/exts/evergreen/emoji_count.py94
-rw-r--r--bot/exts/evergreen/fun.py24
-rw-r--r--bot/exts/evergreen/githubinfo.py98
-rw-r--r--bot/exts/evergreen/issues.py5
-rw-r--r--bot/exts/evergreen/movie.py5
-rw-r--r--bot/exts/evergreen/trivia_quiz.py6
-rw-r--r--bot/exts/halloween/hacktoberstats.py276
-rw-r--r--bot/exts/halloween/scarymovie.py3
-rw-r--r--bot/exts/valentines/movie_generator.py2
11 files changed, 420 insertions, 135 deletions
diff --git a/bot/constants.py b/bot/constants.py
index e113428e..f1f34886 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -75,6 +75,24 @@ class Channels(NamedTuple):
show_your_projects = int(environ.get("CHANNEL_SHOW_YOUR_PROJECTS", 303934982764625920))
show_your_projects_discussion = 360148304664723466
hacktoberfest_2020 = 760857070781071431
+ voice_chat = 412357430186344448
+
+ # Core Dev Sprint channels
+ sprint_announcements = 755958119963557958
+ sprint_information = 753338352136224798
+ sprint_organisers = 753340132639375420
+ sprint_general = 753340631538991305
+ sprint_social1_cheese_shop = 758779754789863514
+ sprint_social2_pet_shop = 758780951978573824
+ sprint_escape_room = 761031075942105109
+ sprint_stdlib = 758553316732698634
+ sprint_asyncio = 762904152438472714
+ sprint_typing = 762904690341838888
+ sprint_discussion_capi = 758553358587527218
+ sprint_discussion_triage = 758553458365300746
+ sprint_discussion_design = 758553492662255616
+ sprint_discussion_mentor = 758553536623280159
+ sprint_documentation = 761038271127093278
class Client(NamedTuple):
@@ -209,6 +227,24 @@ WHITELISTED_CHANNELS = (
Channels.off_topic_0,
Channels.off_topic_1,
Channels.off_topic_2,
+ Channels.voice_chat,
+
+ # Core Dev Sprint Channels
+ Channels.sprint_announcements,
+ Channels.sprint_information,
+ Channels.sprint_organisers,
+ Channels.sprint_general,
+ Channels.sprint_social1_cheese_shop,
+ Channels.sprint_social2_pet_shop,
+ Channels.sprint_escape_room,
+ Channels.sprint_stdlib,
+ Channels.sprint_asyncio,
+ Channels.sprint_typing,
+ Channels.sprint_discussion_capi,
+ Channels.sprint_discussion_triage,
+ Channels.sprint_discussion_design,
+ Channels.sprint_discussion_mentor,
+ Channels.sprint_documentation,
)
# Bot replies
diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py
index 8977534f..3c612eb1 100644
--- a/bot/exts/easter/easter_riddle.py
+++ b/bot/exts/easter/easter_riddle.py
@@ -22,7 +22,7 @@ class EasterRiddle(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
- self.winners = []
+ self.winners = set()
self.correct = ""
self.current_channel = None
@@ -79,7 +79,7 @@ class EasterRiddle(commands.Cog):
await ctx.send(content, embed=answer_embed)
- self.winners = []
+ self.winners.clear()
self.current_channel = None
@commands.Cog.listener()
@@ -92,7 +92,7 @@ class EasterRiddle(commands.Cog):
return
if message.content.lower() == self.correct.lower():
- self.winners.append(message.author.mention)
+ self.winners.add(message.author.mention)
def setup(bot: commands.Bot) -> None:
diff --git a/bot/exts/evergreen/emoji_count.py b/bot/exts/evergreen/emoji_count.py
index ef900199..cc43e9ab 100644
--- a/bot/exts/evergreen/emoji_count.py
+++ b/bot/exts/evergreen/emoji_count.py
@@ -1,12 +1,14 @@
import datetime
import logging
import random
-from typing import Dict, Optional
+from collections import defaultdict
+from typing import List, Tuple
import discord
from discord.ext import commands
from bot.constants import Colours, ERROR_REPLIES
+from bot.utils.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -17,73 +19,77 @@ class EmojiCount(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
- def embed_builder(self, emoji: dict) -> discord.Embed:
+ @staticmethod
+ def embed_builder(emoji: dict) -> Tuple[discord.Embed, List[str]]:
"""Generates an embed with the emoji names and count."""
embed = discord.Embed(
color=Colours.orange,
title="Emoji Count",
timestamp=datetime.datetime.utcnow()
)
+ msg = []
if len(emoji) == 1:
- for key, value in emoji.items():
- embed.description = f"There are **{len(value)}** emojis in the **{key}** category"
- embed.set_thumbnail(url=random.choice(value).url)
+ for category_name, category_emojis in emoji.items():
+ if len(category_emojis) == 1:
+ msg.append(f"There is **{len(category_emojis)}** emoji in **{category_name}** category")
+ else:
+ msg.append(f"There are **{len(category_emojis)}** emojis in **{category_name}** category")
+ embed.set_thumbnail(url=random.choice(category_emojis).url)
+
else:
- msg = ''
- for key, value in emoji.items():
- emoji_choice = random.choice(value)
- emoji_info = f'There are **{len(value)}** emojis in the **{key}** category\n'
- msg += f'<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}'
- embed.description = msg
- return embed
+ for category_name, category_emojis in emoji.items():
+ emoji_choice = random.choice(category_emojis)
+ if len(category_emojis) > 1:
+ emoji_info = f"There are **{len(category_emojis)}** emojis in **{category_name}** category"
+ else:
+ emoji_info = f"There is **{len(category_emojis)}** emoji in **{category_name}** category"
+ if emoji_choice.animated:
+ msg.append(f'<a:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}')
+ else:
+ msg.append(f'<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}')
+ return embed, msg
@staticmethod
- def generate_invalid_embed(ctx: commands.Context) -> discord.Embed:
- """Genrates error embed."""
+ def generate_invalid_embed(emojis: list) -> Tuple[discord.Embed, List[str]]:
+ """Generates error embed."""
embed = discord.Embed(
color=Colours.soft_red,
title=random.choice(ERROR_REPLIES)
)
+ msg = []
- emoji_dict = {}
- for emoji in ctx.guild.emojis:
- emoji_dict[emoji.name.split("_")[0]] = []
+ emoji_dict = defaultdict(list)
+ for emoji in emojis:
+ emoji_dict[emoji.name.split("_")[0]].append(emoji)
- error_comp = ', '.join(key for key in emoji_dict.keys())
- embed.description = f"These are the valid categories\n```{error_comp}```"
- return embed
+ error_comp = ', '.join(emoji_dict)
+ msg.append(f"These are the valid categories\n```{error_comp}```")
+ return embed, msg
- def emoji_list(self, ctx: commands.Context, categories: dict) -> Dict:
- """Generates an embed with the emoji names and count."""
- out = {category: [] for category in categories}
+ @commands.command(name="emojicount", aliases=["ec", "emojis"])
+ async def emoji_count(self, ctx: commands.Context, *, category_query: str = None) -> None:
+ """Returns embed with emoji category and info given by the user."""
+ emoji_dict = defaultdict(list)
+ if not ctx.guild.emojis:
+ await ctx.send("No emojis found.")
+ return
+ log.trace(f"Emoji Category {'' if category_query else 'not '}provided by the user")
for emoji in ctx.guild.emojis:
- category = emoji.name.split('_')[0]
- if category in out:
- out[category].append(emoji)
- return out
-
- @commands.command(name="emoji_count", aliases=["ec"])
- async def ec(self, ctx: commands.Context, *, emoji: str = None) -> Optional[str]:
- """Returns embed with emoji category and info given by the user."""
- emoji_dict = {}
+ emoji_category = emoji.name.split("_")[0]
- for a in ctx.guild.emojis:
- if emoji is None:
- log.trace("Emoji Category not provided by the user")
- emoji_dict.update({a.name.split("_")[0]: []})
- elif a.name.split("_")[0] in emoji:
- log.trace("Emoji Category provided by the user")
- emoji_dict.update({a.name.split("_")[0]: []})
+ if category_query is not None and emoji_category not in category_query:
+ continue
- emoji_dict = self.emoji_list(ctx, emoji_dict)
+ emoji_dict[emoji_category].append(emoji)
- if len(emoji_dict) == 0:
- embed = self.generate_invalid_embed(ctx)
+ if not emoji_dict:
+ log.trace("Invalid name provided by the user")
+ embed, msg = self.generate_invalid_embed(ctx.guild.emojis)
else:
- embed = self.embed_builder(emoji_dict)
- await ctx.send(embed=embed)
+ embed, msg = self.embed_builder(emoji_dict)
+ await LinePaginator.paginate(lines=msg, ctx=ctx, embed=embed)
def setup(bot: commands.Bot) -> None:
diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py
index de6a92c6..101725da 100644
--- a/bot/exts/evergreen/fun.py
+++ b/bot/exts/evergreen/fun.py
@@ -7,10 +7,10 @@ from typing import Callable, Iterable, Tuple, Union
from discord import Embed, Message
from discord.ext import commands
-from discord.ext.commands import Bot, Cog, Context, MessageConverter, clean_content
+from discord.ext.commands import BadArgument, Bot, Cog, Context, MessageConverter, clean_content
from bot import utils
-from bot.constants import Colours, Emojis
+from bot.constants import Client, Colours, Emojis
log = logging.getLogger(__name__)
@@ -57,18 +57,20 @@ class Fun(Cog):
with Path("bot/resources/evergreen/caesar_info.json").open("r", encoding="UTF-8") as f:
self._caesar_cipher_embed = json.load(f)
+ @staticmethod
+ def _get_random_die() -> str:
+ """Generate a random die emoji, ready to be sent on Discord."""
+ die_name = f"dice_{random.randint(1, 6)}"
+ return getattr(Emojis, die_name)
+
@commands.command()
async def roll(self, ctx: Context, num_rolls: int = 1) -> None:
"""Outputs a number of random dice emotes (up to 6)."""
- output = ""
- if num_rolls > 6:
- num_rolls = 6
- elif num_rolls < 1:
- output = ":no_entry: You must roll at least once."
- for _ in range(num_rolls):
- dice = f"dice_{random.randint(1, 6)}"
- output += getattr(Emojis, dice, '')
- await ctx.send(output)
+ if 1 <= num_rolls <= 6:
+ dice = " ".join(self._get_random_die() for _ in range(num_rolls))
+ await ctx.send(dice)
+ else:
+ raise BadArgument(f"`{Client.prefix}roll` only supports between 1 and 6 rolls.")
@commands.command(name="uwu", aliases=("uwuwize", "uwuify",))
async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None:
diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py
new file mode 100644
index 00000000..2e38e3ab
--- /dev/null
+++ b/bot/exts/evergreen/githubinfo.py
@@ -0,0 +1,98 @@
+import logging
+import random
+from datetime import datetime
+from typing import Optional
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+from bot.constants import NEGATIVE_REPLIES
+
+log = logging.getLogger(__name__)
+
+
+class GithubInfo(commands.Cog):
+ """Fetches info from GitHub."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ async def fetch_data(self, url: str) -> dict:
+ """Retrieve data as a dictionary."""
+ async with self.bot.http_session.get(url) as r:
+ return await r.json()
+
+ @commands.command(name='github', aliases=['gh'])
+ @commands.cooldown(1, 60, BucketType.user)
+ async def get_github_info(self, ctx: commands.Context, username: Optional[str]) -> None:
+ """
+ Fetches a user's GitHub information.
+
+ Username is optional and sends the help command if not specified.
+ """
+ if username is None:
+ await ctx.invoke(self.bot.get_command('help'), 'github')
+ ctx.command.reset_cooldown(ctx)
+ return
+
+ async with ctx.typing():
+ user_data = await self.fetch_data(f"https://api.github.com/users/{username}")
+
+ # User_data will not have a message key if the user exists
+ if user_data.get('message') is not None:
+ await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES),
+ description=f"The profile for `{username}` was not found.",
+ colour=discord.Colour.red()))
+ return
+
+ org_data = await self.fetch_data(user_data['organizations_url'])
+ orgs = [f"[{org['login']}](https://github.com/{org['login']})" for org in org_data]
+ orgs_to_add = ' | '.join(orgs)
+
+ gists = user_data['public_gists']
+
+ # Forming blog link
+ if user_data['blog'].startswith("http"): # Blog link is complete
+ blog = user_data['blog']
+ elif user_data['blog']: # Blog exists but the link is not complete
+ blog = f"https://{user_data['blog']}"
+ else:
+ blog = "No website link available"
+
+ embed = discord.Embed(
+ title=f"`{user_data['login']}`'s GitHub profile info",
+ description=f"```{user_data['bio']}```\n" if user_data['bio'] is not None else "",
+ colour=0x7289da,
+ url=user_data['html_url'],
+ timestamp=datetime.strptime(user_data['created_at'], "%Y-%m-%dT%H:%M:%SZ")
+ )
+ embed.set_thumbnail(url=user_data['avatar_url'])
+ embed.set_footer(text="Account created at")
+
+ if user_data['type'] == "User":
+
+ embed.add_field(name="Followers",
+ value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)")
+ embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(name="Following",
+ value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)")
+
+ embed.add_field(name="Public repos",
+ value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)")
+ embed.add_field(name="\u200b", value="\u200b")
+
+ if user_data['type'] == "User":
+ embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{username})")
+
+ embed.add_field(name=f"Organization{'s' if len(orgs)!=1 else ''}",
+ value=orgs_to_add if orgs else "No organizations")
+ embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(name="Website", value=blog)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Adding the cog to the bot."""
+ bot.add_cog(GithubInfo(bot))
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
index 5a5c82e7..97ee6a12 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -38,7 +38,7 @@ class Issues(commands.Cog):
) -> None:
"""Command to retrieve issue(s) from a GitHub repository."""
links = []
- numbers = set(numbers)
+ numbers = set(numbers) # Convert from list to set to remove duplicates, if any
if not numbers:
await ctx.invoke(self.bot.get_command('help'), 'issue')
@@ -53,8 +53,7 @@ class Issues(commands.Cog):
await ctx.send(embed=embed)
return
- for number in set(numbers):
- # Convert from list to set to remove duplicates, if any.
+ 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"
diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py
index 93aeef30..340a5724 100644
--- a/bot/exts/evergreen/movie.py
+++ b/bot/exts/evergreen/movie.py
@@ -190,7 +190,10 @@ class Movie(Cog):
async def get_embed(self, name: str) -> Embed:
"""Return embed of random movies. Uses name in title."""
- return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)')
+ embed = Embed(title=f"Random {name} Movies")
+ embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.")
+ embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")
+ return embed
def setup(bot: Bot) -> None:
diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py
index 8dceceac..fe692c2a 100644
--- a/bot/exts/evergreen/trivia_quiz.py
+++ b/bot/exts/evergreen/trivia_quiz.py
@@ -121,8 +121,10 @@ class TriviaQuiz(commands.Cog):
# A function to check whether user input is the correct answer(close to the right answer)
def check(m: discord.Message) -> bool:
- ratio = fuzz.ratio(answer.lower(), m.content.lower())
- return ratio > 85 and m.channel == ctx.channel
+ return (
+ m.channel == ctx.channel
+ and fuzz.ratio(answer.lower(), m.content.lower()) > 85
+ )
try:
msg = await self.bot.wait_for('message', check=check, timeout=10)
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
index ed1755e3..d2762513 100644
--- a/bot/exts/halloween/hacktoberstats.py
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -2,9 +2,9 @@ import json
import logging
import re
from collections import Counter
-from datetime import datetime
+from datetime import datetime, timedelta
from pathlib import Path
-from typing import List, Tuple
+from typing import List, Tuple, Union
import aiohttp
import discord
@@ -18,11 +18,15 @@ log = logging.getLogger(__name__)
CURRENT_YEAR = datetime.now().year # Used to construct GH API query
PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded
+REVIEW_DAYS = 14 # number of days needed after PR can be mature
HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2020,)
REQUEST_HEADERS = {"User-Agent": "Python Discord Hacktoberbot"}
+# using repo topics API during preview period requires an accept header
+GITHUB_TOPICS_ACCEPT_HEADER = {"Accept": "application/vnd.github.mercy-preview+json"}
if GITHUB_TOKEN := Tokens.github:
REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
+ GITHUB_TOPICS_ACCEPT_HEADER["Authorization"] = f"token {GITHUB_TOKEN}"
GITHUB_NONEXISTENT_USER_MESSAGE = (
"The listed users cannot be searched either because the users do not exist "
@@ -50,7 +54,7 @@ class HacktoberStats(commands.Cog):
get that user's contributions
"""
if not github_username:
- author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+ author_id, author_mention = self._author_mention_from_context(ctx)
if str(author_id) in self.linked_accounts.keys():
github_username = self.linked_accounts[author_id]["github_username"]
@@ -81,7 +85,7 @@ class HacktoberStats(commands.Cog):
}
}
"""
- author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+ author_id, author_mention = self._author_mention_from_context(ctx)
if github_username:
if str(author_id) in self.linked_accounts.keys():
old_username = self.linked_accounts[author_id]["github_username"]
@@ -106,7 +110,7 @@ class HacktoberStats(commands.Cog):
@override_in_channel(HACKTOBER_WHITELIST)
async def unlink_user(self, ctx: commands.Context) -> None:
"""Remove the invoking user's account link from the log."""
- author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+ author_id, author_mention = self._author_mention_from_context(ctx)
stored_user = self.linked_accounts.pop(author_id, None)
if stored_user:
@@ -162,7 +166,11 @@ class HacktoberStats(commands.Cog):
"""
Query GitHub's API for PRs created by a GitHub user during the month of October.
- PRs with the 'invalid' tag are ignored
+ PRs with an 'invalid' or 'spam' label are ignored
+
+ For PRs created after October 3rd, they have to be in a repository that has a
+ 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it
+ to count.
If a valid github_username is provided, an embed is generated and posted to the channel
@@ -172,19 +180,19 @@ class HacktoberStats(commands.Cog):
prs = await self.get_october_prs(github_username)
if prs:
- stats_embed = self.build_embed(github_username, prs)
+ stats_embed = await self.build_embed(github_username, prs)
await ctx.send('Here are some stats!', embed=stats_embed)
else:
- await ctx.send(f"No October GitHub contributions found for '{github_username}'")
+ await ctx.send(f"No valid October GitHub contributions found for '{github_username}'")
- def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed:
+ async def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed:
"""Return a stats embed built from github_username's PRs."""
logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'")
- pr_stats = self._summarize_prs(prs)
+ in_review, accepted = await self._categorize_prs(prs)
- n = pr_stats['n_prs']
+ n = len(accepted) + len(in_review) # total number of PRs
if n >= PRS_FOR_SHIRT:
- shirtstr = f"**{github_username} has earned a T-shirt or a tree!**"
+ shirtstr = f"**{github_username} is eligible for a T-shirt or a tree!**"
elif n == PRS_FOR_SHIRT - 1:
shirtstr = f"**{github_username} is 1 PR away from a T-shirt or a tree!**"
else:
@@ -194,8 +202,8 @@ class HacktoberStats(commands.Cog):
title=f"{github_username}'s Hacktoberfest",
color=discord.Color(0x9c4af7),
description=(
- f"{github_username} has made {n} "
- f"{HacktoberStats._contributionator(n)} in "
+ f"{github_username} has made {n} valid "
+ f"{self._contributionator(n)} in "
f"October\n\n"
f"{shirtstr}\n\n"
)
@@ -207,54 +215,64 @@ class HacktoberStats(commands.Cog):
url="https://hacktoberfest.digitalocean.com",
icon_url="https://avatars1.githubusercontent.com/u/35706162?s=200&v=4"
)
+
+ # this will handle when no PRs in_review or accepted
+ review_str = self._build_prs_string(in_review, github_username) or "None"
+ accepted_str = self._build_prs_string(accepted, github_username) or "None"
stats_embed.add_field(
- name="Top 5 Repositories:",
- value=self._build_top5str(pr_stats)
+ name=":clock1: In Review",
+ value=review_str
+ )
+ stats_embed.add_field(
+ name=":tada: Accepted",
+ value=accepted_str
)
logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'")
return stats_embed
@staticmethod
- async def get_october_prs(github_username: str) -> List[dict]:
+ async def get_october_prs(github_username: str) -> Union[List[dict], None]:
"""
Query GitHub's API for PRs created during the month of October by github_username.
- PRs with an 'invalid' tag are ignored
+ PRs with an 'invalid' or 'spam' label are ignored unless it is merged or approved
+
+ For PRs created after October 3rd, they have to be in a repository that has a
+ 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it
+ to count.
If PRs are found, return a list of dicts with basic PR information
For each PR:
- {
+ {
"repo_url": str
"repo_shortname": str (e.g. "python-discord/seasonalbot")
"created_at": datetime.datetime
- }
+ "number": int
+ }
Otherwise, return None
"""
- logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'")
+ logging.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'")
base_url = "https://api.github.com/search/issues?q="
- not_label = "invalid"
action_type = "pr"
- is_query = f"public+author:{github_username}"
+ is_query = "public"
not_query = "draft"
- date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00"
+ date_range = f"{CURRENT_YEAR}-09-30T10:00Z..{CURRENT_YEAR}-11-01T12:00Z"
per_page = "300"
query_url = (
f"{base_url}"
- f"-label:{not_label}"
f"+type:{action_type}"
f"+is:{is_query}"
+ f"+author:{github_username}"
f"+-is:{not_query}"
f"+created:{date_range}"
f"&per_page={per_page}"
)
+ logging.debug(f"GitHub query URL generated: {query_url}")
- async with aiohttp.ClientSession() as session:
- async with session.get(query_url, headers=REQUEST_HEADERS) as resp:
- jsonresp = await resp.json()
-
+ jsonresp = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS)
if "message" in jsonresp.keys():
# One of the parameters is invalid, short circuit for now
api_message = jsonresp["errors"][0]["message"]
@@ -264,28 +282,132 @@ class HacktoberStats(commands.Cog):
logging.debug(f"No GitHub user found named '{github_username}'")
else:
logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
+ return
+ if jsonresp["total_count"] == 0:
+ # Short circuit if there aren't any PRs
+ logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'")
return
- else:
- if jsonresp["total_count"] == 0:
- # Short circuit if there aren't any PRs
- logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'")
- return
- else:
- logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'")
- outlist = []
- for item in jsonresp["items"]:
- shortname = HacktoberStats._get_shortname(item["repository_url"])
- itemdict = {
- "repo_url": f"https://www.github.com/{shortname}",
- "repo_shortname": shortname,
- "created_at": datetime.strptime(
- item["created_at"], r"%Y-%m-%dT%H:%M:%SZ"
- ),
- }
+ logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'")
+ outlist = [] # list of pr information dicts that will get returned
+ oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=None)
+ hackto_topics = {} # cache whether each repo has the appropriate topic (bool values)
+ for item in jsonresp["items"]:
+ shortname = HacktoberStats._get_shortname(item["repository_url"])
+ itemdict = {
+ "repo_url": f"https://www.github.com/{shortname}",
+ "repo_shortname": shortname,
+ "created_at": datetime.strptime(
+ item["created_at"], r"%Y-%m-%dT%H:%M:%SZ"
+ ),
+ "number": item["number"]
+ }
+
+ # if the PR has 'invalid' or 'spam' labels, the PR must be
+ # either merged or approved for it to be included
+ if HacktoberStats._has_label(item, ["invalid", "spam"]):
+ if not await HacktoberStats._is_accepted(itemdict):
+ continue
+
+ # PRs before oct 3 no need to check for topics
+ # continue the loop if 'hacktoberfest-accepted' is labelled then
+ # there is no need to check for its topics
+ if itemdict["created_at"] < oct3:
+ outlist.append(itemdict)
+ continue
+
+ # checking PR's labels for "hacktoberfest-accepted"
+ if HacktoberStats._has_label(item, "hacktoberfest-accepted"):
+ outlist.append(itemdict)
+ continue
+
+ # no need to query github if repo topics are fetched before already
+ if shortname in hackto_topics.keys():
+ if hackto_topics[shortname]:
outlist.append(itemdict)
- return outlist
+ continue
+ # fetch topics for the pr repo
+ topics_query_url = f"https://api.github.com/repos/{shortname}/topics"
+ logging.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}")
+ jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER)
+ if jsonresp2.get("names") is None:
+ logging.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}")
+ return
+
+ # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label
+ # must be in repo with 'hacktoberfest' topic
+ if "hacktoberfest" in jsonresp2["names"]:
+ hackto_topics[shortname] = True # cache result in the dict for later use if needed
+ outlist.append(itemdict)
+ return outlist
+
+ @staticmethod
+ async def _fetch_url(url: str, headers: dict) -> dict:
+ """Retrieve API response from URL."""
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, headers=headers) as resp:
+ jsonresp = await resp.json()
+ return jsonresp
+
+ @staticmethod
+ def _has_label(pr: dict, labels: Union[List[str], str]) -> bool:
+ """
+ Check if a PR has label 'labels'.
+
+ 'labels' can be a string or a list of strings, if it's a list of strings
+ it will return true if any of the labels match.
+ """
+ if not pr.get("labels"): # if PR has no labels
+ return False
+ if (isinstance(labels, str)) and (any(label["name"].casefold() == labels for label in pr["labels"])):
+ return True
+ for item in labels:
+ if any(label["name"].casefold() == item for label in pr["labels"]):
+ return True
+ return False
+
+ @staticmethod
+ async def _is_accepted(pr: dict) -> bool:
+ """Check if a PR is merged, approved, or labelled hacktoberfest-accepted."""
+ # checking for merge status
+ query_url = f"https://api.github.com/repos/{pr['repo_shortname']}/pulls/"
+ query_url += str(pr["number"])
+ jsonresp = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS)
+
+ if "message" in jsonresp.keys():
+ logging.error(
+ f"Error fetching PR stats for #{pr['number']} in repo {pr['repo_shortname']}:\n"
+ f"{jsonresp['message']}"
+ )
+ return False
+ if ("merged" in jsonresp.keys()) and jsonresp["merged"]:
+ return True
+
+ # checking for the label, using `jsonresp` which has the label information
+ if HacktoberStats._has_label(jsonresp, "hacktoberfest-accepted"):
+ return True
+
+ # checking approval
+ query_url += "/reviews"
+ jsonresp2 = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS)
+ if isinstance(jsonresp2, dict):
+ # if API request is unsuccessful it will be a dict with the error in 'message'
+ logging.error(
+ f"Error fetching PR reviews for #{pr['number']} in repo {pr['repo_shortname']}:\n"
+ f"{jsonresp2['message']}"
+ )
+ return False
+ # if it is successful it will be a list instead of a dict
+ if len(jsonresp2) == 0: # if PR has no reviews
+ return False
+
+ # loop through reviews and check for approval
+ for item in jsonresp2:
+ if "status" in item.keys():
+ if item['status'] == "APPROVED":
+ return True
+ return False
@staticmethod
def _get_shortname(in_url: str) -> str:
@@ -301,38 +423,52 @@ class HacktoberStats(commands.Cog):
return re.findall(exp, in_url)[0]
@staticmethod
- def _summarize_prs(prs: List[dict]) -> dict:
+ async def _categorize_prs(prs: List[dict]) -> tuple:
"""
- Generate statistics from an input list of PR dictionaries, as output by get_october_prs.
+ Categorize PRs into 'in_review' and 'accepted' and returns as a tuple.
- Return a dictionary containing:
- {
- "n_prs": int
- "top5": [(repo_shortname, ncontributions), ...]
- }
+ PRs created less than 14 days ago are 'in_review', PRs that are not
+ are 'accepted' (after 14 days review period).
+
+ PRs that are accepted must either be merged, approved, or labelled
+ 'hacktoberfest-accepted.
"""
- contributed_repos = [pr["repo_shortname"] for pr in prs]
- return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)}
+ now = datetime.now()
+ oct3 = datetime(CURRENT_YEAR, 10, 3, 23, 59, 59, tzinfo=None)
+ in_review = []
+ accepted = []
+ for pr in prs:
+ if (pr['created_at'] + timedelta(REVIEW_DAYS)) > now:
+ in_review.append(pr)
+ elif (pr['created_at'] <= oct3) or await HacktoberStats._is_accepted(pr):
+ accepted.append(pr)
+
+ return in_review, accepted
@staticmethod
- def _build_top5str(stats: List[tuple]) -> str:
+ def _build_prs_string(prs: List[tuple], user: str) -> str:
"""
- Build a string from the Top 5 contributions that is compatible with a discord.Embed field.
+ Builds a discord embed compatible string for a list of PRs.
- Top 5 contributions should be a list of tuples, as output in the stats dictionary by
- _summarize_prs
-
- String is of the form:
- n contribution(s) to [shortname](url)
- ...
+ Repository name with the link to pull requests authored by 'user' for
+ each PR.
"""
base_url = "https://www.github.com/"
- contributionstrs = []
- for repo in stats['top5']:
- n = repo[1]
- contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{repo[0]})")
-
- return "\n".join(contributionstrs)
+ str_list = []
+ repo_list = [pr["repo_shortname"] for pr in prs]
+ prs_list = Counter(repo_list).most_common(5) # get first 5 counted PRs
+ more = len(prs) - sum(i[1] for i in prs_list)
+
+ for pr in prs_list:
+ # for example: https://www.github.com/python-discord/bot/pulls/octocat
+ # will display pull requests authored by octocat.
+ # pr[1] is the number of PRs to the repo
+ string = f"{pr[1]} to [{pr[0]}]({base_url}{pr[0]}/pulls/{user})"
+ str_list.append(string)
+ if more:
+ str_list.append(f"...and {more} more")
+
+ return "\n".join(str_list)
@staticmethod
def _contributionator(n: int) -> str:
diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py
index c80e0298..0807eca6 100644
--- a/bot/exts/halloween/scarymovie.py
+++ b/bot/exts/halloween/scarymovie.py
@@ -121,7 +121,8 @@ class ScaryMovie(commands.Cog):
if value:
embed.add_field(name=name, value=value)
- embed.set_footer(text='powered by themoviedb.org')
+ embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.")
+ embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")
return embed
diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py
index 0843175a..4df9e0d5 100644
--- a/bot/exts/valentines/movie_generator.py
+++ b/bot/exts/valentines/movie_generator.py
@@ -48,6 +48,8 @@ class RomanceMovieFinder(commands.Cog):
embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}")
embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"])
embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"])
+ embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.")
+ embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")
await ctx.send(embed=embed)
except KeyError:
warning_message = "A KeyError was raised while fetching information on the movie. The API service" \