aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/review-policy.yml3
-rw-r--r--.github/workflows/lint.yaml22
-rw-r--r--.github/workflows/review-check.yaml166
-rw-r--r--.github/workflows/status_embed.yaml73
-rw-r--r--bot/constants.py22
-rw-r--r--bot/exts/christmas/advent_of_code/_cog.py27
-rw-r--r--bot/exts/christmas/advent_of_code/_helpers.py65
7 files changed, 193 insertions, 185 deletions
diff --git a/.github/review-policy.yml b/.github/review-policy.yml
new file mode 100644
index 00000000..421b30f8
--- /dev/null
+++ b/.github/review-policy.yml
@@ -0,0 +1,3 @@
+remote: python-discord/.github
+path: review-policies/core-developers.yml
+ref: main
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index 063f406c..a5f45255 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -91,3 +91,25 @@ jobs:
- name: Run flake8
run: "flake8 \
--format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s'"
+
+ # Prepare the Pull Request Payload artifact. If this fails, we
+ # we fail silently using the `continue-on-error` option. It's
+ # nice if this succeeds, but if it fails for any reason, it
+ # does not mean that our lint checks failed.
+ - name: Prepare Pull Request Payload artifact
+ id: prepare-artifact
+ if: always() && github.event_name == 'pull_request'
+ continue-on-error: true
+ run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json
+
+ # This only makes sense if the previous step succeeded. To
+ # get the original outcome of the previous step before the
+ # `continue-on-error` conclusion is applied, we use the
+ # `.outcome` value. This step also fails silently.
+ - name: Upload a Build Artifact
+ if: always() && steps.prepare-artifact.outcome == 'success'
+ continue-on-error: true
+ uses: actions/upload-artifact@v2
+ with:
+ name: pull-request-payload
+ path: pull_request_payload.json
diff --git a/.github/workflows/review-check.yaml b/.github/workflows/review-check.yaml
deleted file mode 100644
index 3e45a4b5..00000000
--- a/.github/workflows/review-check.yaml
+++ /dev/null
@@ -1,166 +0,0 @@
-name: Review Check
-
-# This workflow needs to trigger in two situations:
-#
-# 1. When a pull request is opened, reopened, or synchronized (new commit)
-# This is accomplished using the `pull_request_target` event that triggers in
-# precisely those situations by default. I've opted for `pull_request_target`
-# as we don't need to have access to the PR's code and it's safer to make the
-# secrets we need available to the workflow compared to `pull_request`.
-#
-# The reason we need to run the workflow for this event is because we need to
-# make sure that our check is part of the check suite for the current commit.
-#
-# 2. When a review is added or dismissed.
-# Whenever reviews are submitted or dismissed, the number of Core Developer
-# approvals may obviously change.
-#
-# ---
-#
-# Unfortunately, having two different event triggers means that can't let
-# this workflow fail on its own, as GitHub actions registers a separate check
-# run result per event trigger. As both triggers need to share the success/fail
-# state, we get around that by registering a custom "status".
-on:
- pull_request_review:
- types:
- - submitted
- - dismissed
- pull_request_target:
-
-
-jobs:
- review-check:
- name: Check Core Dev Reviews
- runs-on: ubuntu-latest
-
- steps:
- # Fetch the latest Opinionated reviews from users with write
- # access. We can't narrow it down using a specific team here
- # yet, so we'll do that later.
- - uses: octokit/[email protected]
- id: reviews
- with:
- query: |
- query ($repository: String!, $pr: Int!) {
- repository(owner: "python-discord", name: $repository) {
- pullRequest(number: $pr) {
- latestOpinionatedReviews(last: 100, writersOnly: true) {
- nodes{
- author{
- login
- }
- state
- }
- }
- }
- }
- }
- repository: ${{ github.event.repository.name }}
- pr: ${{ github.event.pull_request.number }}
- env:
- GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }}
-
- # Fetch the members of the Core Developers team so we can
- # check if any of them actually approved this PR.
- - uses: octokit/[email protected]
- id: core_developers
- with:
- query: |
- query {
- organization(login: "python-discord") {
- team(slug: "core-developers") {
- members(first: 100) {
- nodes {
- login
- }
- }
- }
- }
- }
- env:
- GITHUB_TOKEN: ${{ secrets.TEAM_TOKEN }}
-
- # I've opted for a Python script, as that's what most of us
- # are familiar with. We do need to setup Python for that.
- - name: Setup python
- id: python
- uses: actions/setup-python@v2
- with:
- python-version: '3.9'
-
- # This is a small, inline Python script that looks for the
- # intersection between approving reviewers and the core dev
- # team. If that intersection exists, we have at least one
- # approving Core Developer.
- #
- # I've opted to keep this inline as it's relatively small
- # and this workflow will be added to multiple repositories.
- - name: Check for Accepting Core Developers
- id: core_dev_reviews
- run: |
- python -c 'import json
- reviews = json.loads("""${{ steps.reviews.outputs.data }}""")
- reviewers = {
- review["author"]["login"]
- for review in reviews["repository"]["pullRequest"]["latestOpinionatedReviews"]["nodes"]
- if review["state"] == "APPROVED"
- }
- core_devs = json.loads("""${{ steps.core_developers.outputs.data }}""")
- core_devs = {
- member["login"] for member in core_devs["organization"]["team"]["members"]["nodes"]
- }
- approving_core_devs = reviewers & core_devs
- approval_check = "success" if approving_core_devs else "failure"
- print(f"::set-output name=approval_check::{approval_check}")
- '
-
- # This step registers a a new status for the head commit of the pull
- # request. If a status with the same context and description already
- # exists, it will be overwritten. The reason we have to do this is
- # because workflows run for the separate `pull_request_target` and
- #`pull_request_review` events need to share a single result state.
- - name: Add Core Dev Approval status check
- uses: octokit/[email protected]
- with:
- route: POST /repos/:repository/statuses/:sha
- repository: ${{ github.repository }}
- sha: ${{ github.event.pull_request.head.sha }}
- state: ${{ steps.core_dev_reviews.outputs.approval_check }}
- description: At least one core developer needs to approve this PR
- context: Core Dev Approval
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- # If we have at least one Core Developer approval, this step
- # removes the 'waiting for core dev approval' label if it's
- # still present for the PR.
- - name: Remove "waiting for core dev approval" if a core dev approved this PR
- if: >-
- steps.core_dev_reviews.outputs.approval_check == 'success' &&
- contains(github.event.pull_request.labels.*.name, 'waiting for core dev approval')
- uses: octokit/[email protected]
- with:
- route: DELETE /repos/:repository/issues/:number/labels/:label
- repository: ${{ github.repository }}
- number: ${{ github.event.pull_request.number }}
- label: needs core dev approval
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- # If we have do not have one Core Developer approval, this step
- # adds the 'waiting for core dev approval' label if it's not
- # already present for the PR.
- - name: Add "waiting for core dev approval" if no core dev has approved yet
- if: >-
- steps.core_dev_reviews.outputs.approval_check == 'failure' &&
- !contains(github.event.pull_request.labels.*.name, 'waiting for core dev approval')
- uses: octokit/[email protected]
- with:
- route: POST /repos/:repository/issues/:number/labels
- repository: ${{ github.repository }}
- number: ${{ github.event.pull_request.number }}
- labels: |
- - needs core dev approval
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml
new file mode 100644
index 00000000..28caa8c2
--- /dev/null
+++ b/.github/workflows/status_embed.yaml
@@ -0,0 +1,73 @@
+name: Status Embed
+
+on:
+ workflow_run:
+ workflows:
+ - Lint
+ - Build
+ types:
+ - completed
+
+jobs:
+ status_embed:
+ # We send the embed in the following situations:
+ # - Always after the `Build` workflow, as it runs at the
+ # end of our workflow sequence regardless of status.
+ # - Always for the `pull_request` event, as it only
+ # runs one workflow.
+ # - Always run for non-success workflows, as they
+ # terminate the workflow sequence.
+ if: >-
+ (github.event.workflow_run.name == 'Build' && github.event.workflow_run.conclusion != 'skipped') ||
+ github.event.workflow_run.event == 'pull_request' ||
+ github.event.workflow_run.conclusion == 'failure' ||
+ github.event.workflow_run.conclusion == 'cancelled'
+ name: Send Status Embed to Discord
+ runs-on: ubuntu-latest
+
+ steps:
+ # A workflow_run event does not contain all the information
+ # we need for a PR embed. That's why we upload an artifact
+ # with that information in the Lint workflow.
+ - name: Get Pull Request Information
+ id: pr_info
+ if: github.event.workflow_run.event == 'pull_request'
+ run: |
+ curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json
+ DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url')
+ [ -z "$DOWNLOAD_URL" ] && exit 1
+ wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2
+ unzip -p pull_request_payload.zip > pull_request_payload.json
+ [ -s pull_request_payload.json ] || exit 3
+ echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)"
+ echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)"
+ echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)"
+ echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Send an informational status embed to Discord instead of the
+ # standard embeds that Discord sends. This embed will contain
+ # more information and we can fine tune when we actually want
+ # to send an embed.
+ - name: GitHub Actions Status Embed for Discord
+ uses: SebastiaanZ/[email protected]
+ with:
+ # Our GitHub Actions webhook
+ webhook_id: '784184528997842985'
+ webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }}
+
+ # Workflow information
+ workflow_name: ${{ github.event.workflow_run.name }}
+ run_id: ${{ github.event.workflow_run.id }}
+ run_number: ${{ github.event.workflow_run.run_number }}
+ status: ${{ github.event.workflow_run.conclusion }}
+ actor: ${{ github.actor }}
+ repository: ${{ github.repository }}
+ ref: ${{ github.ref }}
+ sha: ${{ github.event.workflow_run.head_sha }}
+
+ pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }}
+ pr_number: ${{ steps.pr_info.outputs.pr_number }}
+ pr_title: ${{ steps.pr_info.outputs.pr_title }}
+ pr_source: ${{ steps.pr_info.outputs.pr_source }}
diff --git a/bot/constants.py b/bot/constants.py
index e313e086..9e6db7a6 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -1,3 +1,4 @@
+import dataclasses
import enum
import logging
from datetime import datetime
@@ -29,11 +30,27 @@ __all__ = (
log = logging.getLogger(__name__)
-class AdventOfCodeLeaderboard(NamedTuple):
+class AdventOfCodeLeaderboard:
id: str
- session: str
+ _session: str
join_code: str
+ # If we notice that the session for this board expired, we set
+ # this attribute to `True`. We will emit a Sentry error so we
+ # can handle it, but, in the meantime, we'll try using the
+ # fallback session to make sure the commands still work.
+ use_fallback_session: bool = False
+
+ @property
+ def session(self) -> str:
+ """Return either the actual `session` cookie or the fallback cookie."""
+ if self.use_fallback_session:
+ log.info(f"Returning fallback cookie for board `{self.id}`.")
+ return AdventOfCode.fallback_session
+
+ return self._session
+
def _parse_aoc_leaderboard_env() -> Dict[str, AdventOfCodeLeaderboard]:
"""
@@ -61,6 +78,7 @@ class AdventOfCode:
# Information for the several leaderboards we have
leaderboards = _parse_aoc_leaderboard_env()
staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "")
+ fallback_session = environ.get("AOC_FALLBACK_SESSION", "")
# Other Advent of Code constants
ignored_days = [day for day in environ.get("AOC_IGNORED_DAYS", "").split(",")]
diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py
index 2a1a776b..0bcd9f42 100644
--- a/bot/exts/christmas/advent_of_code/_cog.py
+++ b/bot/exts/christmas/advent_of_code/_cog.py
@@ -221,7 +221,11 @@ class AdventOfCode(commands.Cog):
if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles):
join_code = AocConfig.leaderboards[AocConfig.staff_leaderboard_id].join_code
else:
- join_code = await _helpers.get_public_join_code(author)
+ try:
+ join_code = await _helpers.get_public_join_code(author)
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Failed to get join code! Notified maintainers.")
+ return
if not join_code:
log.error(f"Failed to get a join code for user {author} ({author.id})")
@@ -256,7 +260,12 @@ class AdventOfCode(commands.Cog):
async def aoc_leaderboard(self, ctx: commands.Context) -> None:
"""Get the current top scorers of the Python Discord Leaderboard."""
async with ctx.typing():
- leaderboard = await _helpers.fetch_leaderboard()
+ try:
+ leaderboard = await _helpers.fetch_leaderboard()
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Unable to fetch leaderboard!")
+ return
+
number_of_participants = leaderboard["number_of_participants"]
top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants)
@@ -291,7 +300,11 @@ class AdventOfCode(commands.Cog):
@override_in_channel(AOC_WHITELIST)
async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None:
"""Send an embed with daily completion statistics for the Python Discord leaderboard."""
- leaderboard = await _helpers.fetch_leaderboard()
+ try:
+ leaderboard = await _helpers.fetch_leaderboard()
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Can't fetch leaderboard for stats right now!")
+ return
# The daily stats are serialized as JSON as they have to be cached in Redis
daily_stats = json.loads(leaderboard["daily_stats"])
@@ -323,8 +336,12 @@ class AdventOfCode(commands.Cog):
many requests to the Advent of Code server.
"""
async with ctx.typing():
- await _helpers.fetch_leaderboard(invalidate_cache=True)
- await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!")
+ try:
+ await _helpers.fetch_leaderboard(invalidate_cache=True)
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Something went wrong while trying to refresh the cache!")
+ else:
+ await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!")
def cog_unload(self) -> None:
"""Cancel season-related tasks on cog unload."""
diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py
index e7eeedb2..d883c09f 100644
--- a/bot/exts/christmas/advent_of_code/_helpers.py
+++ b/bot/exts/christmas/advent_of_code/_helpers.py
@@ -53,6 +53,18 @@ EST = pytz.timezone("EST")
StarResult = collections.namedtuple("StarResult", "member_id completion_time")
+class UnexpectedRedirect(aiohttp.ClientError):
+ """Raised when an unexpected redirect was detected."""
+
+
+class UnexpectedResponseStatus(aiohttp.ClientError):
+ """Raised when an unexpected redirect was detected."""
+
+
+class FetchingLeaderboardFailed(Exception):
+ """Raised when one or more leaderboards could not be fetched at all."""
+
+
def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]:
"""
Provide a sorting value for our leaderboard.
@@ -153,6 +165,23 @@ def _format_leaderboard(leaderboard: typing.Dict[str, dict]) -> str:
return "\n".join(leaderboard_lines)
+async def _leaderboard_request(url: str, board: int, cookies: dict) -> typing.Optional[dict]:
+ """Make a leaderboard request using the specified session cookie."""
+ async with aiohttp.request("GET", url, headers=AOC_REQUEST_HEADER, cookies=cookies) as resp:
+ # The Advent of Code website redirects silently with a 200 response if a
+ # session cookie has expired, is invalid, or was not provided.
+ if str(resp.url) != url:
+ log.error(f"Fetching leaderboard `{board}` failed! Check the session cookie.")
+ raise UnexpectedRedirect(f"redirected unexpectedly to {resp.url} for board `{board}`")
+
+ # Every status other than `200` is unexpected, not only 400+
+ if not resp.status == 200:
+ log.error(f"Unexpected response `{resp.status}` while fetching leaderboard `{board}`")
+ raise UnexpectedResponseStatus(f"status `{resp.status}`")
+
+ return await resp.json()
+
+
async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:
"""Fetch data for all leaderboards and return a pooled result."""
year = AdventOfCode.year
@@ -165,22 +194,34 @@ async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:
participants = {}
for leaderboard in AdventOfCode.leaderboards.values():
leaderboard_url = AOC_API_URL.format(year=year, leaderboard_id=leaderboard.id)
- cookies = {"session": leaderboard.session}
- # We don't need to create a session if we're going to throw it away after each request
- async with aiohttp.request(
- "GET", leaderboard_url, headers=AOC_REQUEST_HEADER, cookies=cookies
- ) as resp:
- if resp.status == 200:
- raw_data = await resp.json()
-
- # Get the participants and store their current count
+ # Two attempts, one with the original session cookie and one with the fallback session
+ for attempt in range(1, 3):
+ log.info(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)")
+ cookies = {"session": leaderboard.session}
+ try:
+ raw_data = await _leaderboard_request(leaderboard_url, leaderboard.id, cookies)
+ except UnexpectedRedirect:
+ if cookies["session"] == AdventOfCode.fallback_session:
+ log.error("It seems like the fallback cookie has expired!")
+ raise FetchingLeaderboardFailed from None
+
+ # If we're here, it means that the original session did not
+ # work. Let's fall back to the fallback session.
+ leaderboard.use_fallback_session = True
+ continue
+ except aiohttp.ClientError:
+ # Don't retry, something unexpected is wrong and it may not be the session.
+ raise FetchingLeaderboardFailed from None
+ else:
+ # Get the participants and store their current count.
board_participants = raw_data["members"]
await _caches.leaderboard_counts.set(leaderboard.id, len(board_participants))
participants.update(board_participants)
- else:
- log.warning(f"Fetching data failed for leaderboard `{leaderboard.id}`")
- resp.raise_for_status()
+ break
+ else:
+ log.error(f"reached 'unreachable' state while fetching board `{leaderboard.id}`.")
+ raise FetchingLeaderboardFailed
log.info(f"Fetched leaderboard information for {len(participants)} participants")
return participants