diff options
-rw-r--r-- | .github/review-policy.yml | 3 | ||||
-rw-r--r-- | .github/workflows/lint.yaml | 22 | ||||
-rw-r--r-- | .github/workflows/review-check.yaml | 166 | ||||
-rw-r--r-- | .github/workflows/status_embed.yaml | 73 | ||||
-rw-r--r-- | bot/constants.py | 22 | ||||
-rw-r--r-- | bot/exts/christmas/advent_of_code/_cog.py | 27 | ||||
-rw-r--r-- | bot/exts/christmas/advent_of_code/_helpers.py | 65 |
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 |