diff --git a/CHANGES.rst b/CHANGES.rst index d11e258e0..3e0448553 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,65 @@ .. Red changelogs +Redbot 3.5.2 (2023-05-14) +========================= + +| Thanks to all these amazing people that contributed to this release: +| :ghuser:`aikaterna`, :ghuser:`flaree`, :ghuser:`Flame442`, :ghuser:`Jackenmen`, :ghuser:`karlsbjorn`, :ghuser:`rramboer`, :ghuser:`synrg`, :ghuser:`TrustyJAID`, :ghuser:`Vexed01` + +End-user changelog +------------------ + +Changes +******* + +- **Core** - Added list of global prefixes to ``redbot --debuginfo `` and ``[p]debuginfo`` (:issue:`6153`) +- **Core - Dependencies** - Red's dependencies have been bumped (:issue:`6155`) +- **Cogs - Downloader** - Updated the code block style in ``[p]repo list`` and ``[p]cog list`` to account for Discord client changes (:issue:`6003`, :issue:`6152`) +- **Cogs - Trivia** - Updated the code block style in the scoreboard to account for Discord client changes (:issue:`6152`) + +Fixes +***** + +- Fixed visual issues with numbered and unnumbered lists caused by Discord's new Markdown support (:issue:`6101`) +- **Core** - Fixed handling of cooldown errors for application commands (:issue:`6159`) +- **Core - Bot Commands** - Added missing backtick to the help of ``[p]set serverprefix`` (:issue:`6004`) +- **Core - Command-line Interfaces** - Fixed ``redbot --debuginfo`` trying to start/starting the bot (:issue:`6131`) +- **Cogs - Audio** - Fixed Audio's managed node trying to allocate 4 GB of memory on 32-bit platforms regardless of how much is actually available (:issue:`6137`, :issue:`6150`) +- **Cogs - Audio** - Fixed song selection in ``[p]search`` always picking the first option when buttons are used (:issue:`6136`, :issue:`6143`) +- **Cogs - CustomCommands** - Fixed parameter handling (:issue:`6138`, :issue:`6149`) +- **Cogs - Mutes** - Fixed ``[p]channelmute`` returning "That user is already muted" error when the user is not actually muted (:issue:`6144`) +- **Cogs - Mutes** - Fixed unexpected error in automatic channel unmuting when the relevant channel is not available (:issue:`6140`, :issue:`6144`) +- **Cogs - Reports** - Fixed ``[p]report`` command not working in DMs (:issue:`6148`) +- **Vendored Packages** - Fixed menus breaking in DMs (:issue:`6139`) + + +Developer changelog +------------------- + +Additions +********* + +- **Core - Data Manager** - Added a new `data_manager.instance_name()` public function (:issue:`6146`) + +Fixes +***** + +- **Core - Utils Package** - Fixed ``menu()`` passing an instance of `discord.PartialEmoji` instead of `str` when a button with a unicode emoji is used (:issue:`6143`) +- **Cogs - Dev** - Fixed issues with exception formatting in ``[p]eval/repl/debug`` commands failing when code from a previous invocation of any of those commands was used (:issue:`6135`) + + +Documentation changes +--------------------- + +Fixes +***** + +- Fixed command choices example in `Slash Commands and Interactions guide ` (:issue:`6154`) +- Updated `the 3.5.0 changelog `, `incompatible-changes-3.5`, and `end-user-guarantees` documents to mention the new ``x86-64-v2`` instruction set requirement (:issue:`6141`, :issue:`6147`) + + +---- + Redbot 3.5.1 (2023-05-04) ========================= @@ -28,6 +88,8 @@ Fixes ---- +.. _redbot-3-5-0-2023-05-04: + Redbot 3.5.0 (2023-05-04) ========================= diff --git a/tools/release_helper.py b/tools/release_helper.py new file mode 100755 index 000000000..d2dfb5ec6 --- /dev/null +++ b/tools/release_helper.py @@ -0,0 +1,948 @@ +#!/usr/bin/env python3.8 +"""Script helping with making releases. + +This script mostly aims to help with the changelog-related tasks but it does also guide you +through the release process steps including running the 'Prepare release' workflow. +""" +import enum +import json +import os +import pydoc +import re +import shlex +import subprocess +import time +import webbrowser +from collections import defaultdict +from typing import Dict, List, Optional + +import click +import requests +import rich +from rich.markdown import Markdown +from typing_extensions import Self + + +class ReleaseType(enum.Enum): + BREAKING = 1 + STANDARD = 2 + MAINTENANCE = 3 + HOTFIX = 4 + + def __str__(self) -> None: + return f"{self.name.lower()} release" + + @classmethod + def from_str(cls, name: str) -> Self: + return cls[name] + + +class ReleaseStage(enum.IntEnum): + WELCOME = enum.auto() + RELEASE_INFO_SET = enum.auto() + RELEASE_BLOCKERS_CHECKED = enum.auto() + OPEN_PULLS_CHECKED = enum.auto() + CHANGELOG_BRANCH_EXISTS = enum.auto() + CHANGELOG_COMMITTED = enum.auto() + CHANGELOG_PR_OPENED = enum.auto() + CHANGELOG_CREATED = enum.auto() + CHANGELOG_REVIEWED = enum.auto() + PREPARE_RELEASE_SPAWNED = enum.auto() + PREPARE_RELEASE_RAN = enum.auto() + AUTOMATED_PULLS_MERGED = enum.auto() + SHORT_LIVED_BRANCH_CREATED = enum.auto() + + @classmethod + def from_str(cls, name: str) -> Self: + return cls[name] + + +GH_FORCE_TTY_ENV = {**os.environ, "GH_FORCE_TTY": "100%"} +GET_MILESTONE_CONTRIBUTORS_QUERY = """ +query getMilestoneContributors( + $milestone: String!, + $after: String, + $states: [PullRequestState!], +) { + repository(owner: "Cog-Creators", name: "Red-DiscordBot") { + milestones(first: 1, query: $milestone) { + nodes { + title + pullRequests(first: 100, after: $after, states: $states) { + nodes { + title + number + author { + login + } + latestOpinionatedReviews(first: 100, writersOnly: true) { + nodes { + author { + login + } + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } +} +""" +# technically not *all* but enough for what we use it for +GET_ALL_TAG_COMMITS_QUERY = """ +query getAllTagCommits { + repository(owner: "Cog-Creators", name: "Red-DiscordBot") { + refs( + refPrefix: "refs/tags/" + orderBy: {direction: DESC, field: TAG_COMMIT_DATE} + first: 100 + ) { + nodes { + name + target { + ... on Commit { + oid + } + } + } + } + } +} +""" +GET_COMMIT_HISTORY_QUERY = """ +query getCommitHistory($refQualifiedName: String!, $after: String) { + repository(owner: "Cog-Creators", name: "Red-DiscordBot") { + ref(qualifiedName: $refQualifiedName) { + target { + ... on Commit { + history(first: 100, after: $after) { + nodes { + oid + abbreviatedOid + messageHeadline + associatedPullRequests(first: 1) { + nodes { + milestone { + title + } + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } + } +} +""" +GH_URL = "https://github.com/Cog-Creators/Red-DiscordBot" +LINKIFY_ISSUE_REFS_RE = re.compile(r"#(\d+)") + + +def get_github_token() -> str: + return subprocess.check_output(("gh", "auth", "token"), text=True).strip() + + +def get_version_to_release() -> str: + import redbot # this needs to be imported after proper branch is checked out + + version_info = redbot.VersionInfo.from_str(redbot._VERSION) + version_info.dev_release = None + return str(version_info) + + +def check_git_dirty() -> None: + if subprocess.check_output(("git", "status", "--porcelain")): + raise click.ClickException( + "Your working tree contains changes," + " please stash or commit them before using this command." + ) + + +def git_current_branch() -> str: + branch = subprocess.check_output(("git", "branch", "--show-current"), text=True).strip() + if not branch: + raise click.ClickException("Could not detect current branch.") + return branch + + +def git_verify_branch(release_type: ReleaseType, base_branch: str = "") -> str: + current_branch = git_current_branch() + if base_branch and current_branch != base_branch: + raise click.ClickException( + f"This release were being done from {base_branch} branch" + " but a different branch is now checked out, aborting..." + ) + if release_type is ReleaseType.BREAKING: + if current_branch != "V3/develop": + raise click.ClickException( + f"A {release_type} must be done from V3/develop, aborting..." + ) + if re.fullmatch(r"V3/develop|3\.\d+", current_branch) is None: + raise click.ClickException( + f"A {release_type} must be done from V3/develop or 3.x branch, aborting..." + ) + return current_branch + + +def pause() -> None: + click.prompt( + "\nHit Enter to continue...\n", + default="", + hide_input=True, + show_default=False, + prompt_suffix="", + ) + + +def print_markdown(text: str) -> None: + rich.print(Markdown(text)) + + +def linkify_issue_refs_cli(text: str) -> str: + return LINKIFY_ISSUE_REFS_RE.sub( + "\x1b]8;;" rf"{GH_URL}/issues/\1" "\x1b\\\\" r"\g<0>" "\x1b]8;;\x1b\\\\", + text, + ) + + +def linkify_issue_refs_md(text: str) -> str: + return LINKIFY_ISSUE_REFS_RE.sub(rf"[\g<0>]({GH_URL}/issues/\1)", text) + + +def get_git_config_value(key: str) -> str: + try: + return subprocess.check_output( + ("git", "config", "--local", "--get", f"red-release-helper.{key}"), text=True + ).strip() + except subprocess.CalledProcessError: + return "" + + +def set_git_config_value(key: str, value: str) -> str: + subprocess.check_call(("git", "config", "--local", f"red-release-helper.{key}", value)) + + +def wipe_git_config_values() -> str: + try: + subprocess.check_output( + ("git", "config", "--local", "--remove-section", "red-release-helper") + ) + except subprocess.CalledProcessError: + pass + + +def get_release_type() -> ReleaseType: + return ReleaseType.from_str(get_git_config_value("release-type")) + + +def set_release_type(release_type: ReleaseType) -> None: + set_git_config_value("release-type", release_type.name) + + +def get_base_branch() -> str: + base_branch = get_git_config_value("base-branch") + if not base_branch: + raise ValueError("Base branch name for this release could not be found in git config.") + return base_branch + + +def set_base_branch(base_branch: str) -> None: + set_git_config_value("base-branch", base_branch) + + +def get_changelog_branch() -> str: + changelog_branch = get_git_config_value("changelog-branch") + if not changelog_branch: + raise ValueError( + "Changelog branch name for this release could not be found in git config." + ) + return changelog_branch + + +def set_changelog_branch(changelog_branch: str) -> None: + set_git_config_value("changelog-branch", changelog_branch) + + +def get_version() -> str: + version = get_git_config_value("release-version") + if not version: + raise ValueError("Release version could not be found in git config.") + return version + + +def set_version(version: str) -> None: + set_git_config_value("release-version", version) + + +def get_release_stage() -> ReleaseStage: + return ReleaseStage.from_str(get_git_config_value("release-stage") or "WELCOME") + + +def set_release_stage(stage: ReleaseStage) -> None: + return set_git_config_value("release-stage", stage.name) + + +@click.group(invoke_without_command=True) +@click.option("--continue", "abort", flag_value=False, default=None) +@click.option("--abort", "abort", flag_value=True, default=None) +def cli(*, abort: bool = None): + """Red's release helper, guiding you through the whole process!""" + stage = get_release_stage() + if abort is True: + if stage is not ReleaseStage.WELCOME: + wipe_git_config_values() + rich.print("Cleaned the pending release.") + else: + rich.print("Nothing to do - there's no pending release.") + return + if stage is not ReleaseStage.WELCOME and abort is not False: + raise click.ClickException( + "It seems that there is a release in progress. You can continue the process with" + " `--continue` flag or abort it with `--abort` flag." + ) + if stage <= ReleaseStage.WELCOME: + check_git_dirty() + rich.print( + "Welcome to Red's release helper!\n" + "--------------------------------\n" + "I'll be guiding you through most of the process to make it as easy as possible.\n" + "You can find the release process documentation here:" + " https://red-devguide.readthedocs.io/core-devs/release-process/\n" + ) + if stage < ReleaseStage.RELEASE_INFO_SET: + print_markdown( + "1. Breaking release (`3.x+1.0`)\n\n" + " Release with breaking changes, done from `V3/develop`.\n" + "2. Standard release (`3.x.y+1`)\n\n" + " Release without breaking changes that may contain both features and bugfixes.\n" + " This is done from `V3/develop` or `3.x` branch" + " if a breaking release is currently in development.\n" + "3. Maintenance release (`3.x.y+1`)\n\n" + " Release without breaking changes that only contains cherry-picked enhancements" + " and bugfixes.\n" + " Quite similar to a standard release but it is done from a short-lived release" + " branch using the tag of a previous version as a base.\n" + "4. Hotfix release (`3.x.y+1`)\n\n" + " Release that is meant to quickly patch one of the currently supported releases" + " (usually it is just the latest).\n" + " This is done from a short-lived release branch using the tag of" + " a previous version as a base, or from `V3/develop`/`3.x`" + " if it doesn’t contain any meaningful code changes yet." + ) + release_type = ReleaseType( + int( + click.prompt( + "\nWhat kind of release is this?", type=click.Choice(["1", "2", "3", "4"]) + ) + ) + ) + set_base_branch(git_verify_branch(release_type)) + set_release_type(release_type) + version = get_version_to_release() + if not click.confirm(f"The version you want to release is {version}, is that correct?"): + raise click.ClickException( + "Please check out the branch that you want to release from" + " and start this program again." + ) + set_version(version) + set_release_stage(ReleaseStage.RELEASE_INFO_SET) + else: + release_type = get_release_type() + version = get_version() + + rich.print("Alright, let's do this!\n") + + for step in STEPS: + step(release_type, version) + + rich.print(Markdown("# Step 8+: Follow the release process documentation")) + rich.print( + "You can continue following the release process documentation from step 8:\n" + "https://red-devguide.readthedocs.io/core-devs/release-process/" + ) + wipe_git_config_values() + + +def ensure_no_release_blockers(release_type: ReleaseType, version: str) -> None: + rich.print(Markdown("# Step 1: Ensure there are no release blockers")) + if get_release_stage() >= ReleaseStage.RELEASE_BLOCKERS_CHECKED: + rich.print(":white_check_mark: Already done!") + return + if release_type is ReleaseType.HOTFIX: + rich.print( + Markdown( + "You can *generally* skip this. Might still be worth checking in case there is" + " some blocker related to the release workflow that could potentially affect you." + ) + ) + else: + rich.print( + "Look at the milestone for the next release and check if there are any" + " Release Blockers (labelled as 'Release Blocker' on the issue tracker)" + " that need to be handled before the release." + ) + + output = subprocess.check_output( + ( + "gh", + "pr", + "list", + "--json=number,title,state", + "--template", + "{{if .}}" + '{{tablerow "NUMBER" "STATE" "TITLE"}}{{range .}}' + '{{tablerow (printf "#%v" .number) .state .title}}{{end}}{{tablerender}}' + "{{end}}", + "--limit=999", + "--state=all", + "--search", + f'milestone:{version} label:"Release Blocker"', + ), + text=True, + ) + + rich.print(Markdown("\n## List of release blockers")) + if output: + print(linkify_issue_refs_cli(output)) + else: + rich.print("There are no release blockers in current milestone.") + pause() + set_release_stage(ReleaseStage.RELEASE_BLOCKERS_CHECKED) + + +def check_state_of_open_pulls(release_type: ReleaseType, version: str) -> None: + rich.print(Markdown("# Step 2: Check state of all open pull requests for this milestone")) + if get_release_stage() >= ReleaseStage.OPEN_PULLS_CHECKED: + rich.print(":white_check_mark: Already done!") + return + if release_type is ReleaseType.HOTFIX: + rich.print( + "This is a hotfix release, you should focus on getting the critical fix out," + " the other PRs should not be important. However, you should still update" + " the milestone to make your and others’ job easier later." + ) + else: + rich.print( + Markdown( + "Decide which PRs should be kept for the release, cooperate with another org member(s)" + " on this. Move any pull requests not targeted for release to a new milestone with" + " name of the release that should come after current one." + ) + ) + + output = subprocess.check_output( + ( + "gh", + "pr", + "list", + "--json=number,title,state", + "--template", + "{{if .}}" + '{{tablerow "NUMBER" "STATE" "TITLE"}}{{range .}}' + '{{tablerow (printf "#%v" .number) .state .title}}{{end}}{{tablerender}}' + "{{end}}", + "--limit=999", + "--search", + f"milestone:{version}", + ), + text=True, + ) + + rich.print(Markdown(f"\n## Open pull requests in milestone {version}")) + if output: + print(linkify_issue_refs_cli(output)) + else: + rich.print("There are no open pull requests left.") + + pause() + set_release_stage(ReleaseStage.OPEN_PULLS_CHECKED) + + +def create_changelog(release_type: ReleaseType, version: str) -> None: + rich.print(Markdown("# Step 3: Create changelog PR")) + if get_release_stage() >= ReleaseStage.CHANGELOG_CREATED: + rich.print(":white_check_mark: Already done!") + return + rich.print( + Markdown( + "The changelog PR should always be merged into `V3/develop`." + " You should remember to later cherry-pick/backport it to a proper branch" + " if you’re not making a release from `V3/develop`." + ) + ) + if release_type is ReleaseType.HOTFIX: + rich.print( + "Hotfix releases [bold]need to[/] contain a changelog.\n" + "It can be limited to a short description of what the hotfix release fixes," + " for example see:" + " [link=https://docs.discord.red/en/stable/changelog.html#redbot-3-4-12-2021-06-17]" + "Red 3.4.12 changelog" + "[/]" + ) + else: + rich.print("Time for a changelog!") + + if click.confirm("Do you have a changelog already?"): + set_release_stage(ReleaseStage.CHANGELOG_CREATED) + return + rich.print() + if get_release_stage() >= ReleaseStage.CHANGELOG_BRANCH_EXISTS: + changelog_branch = get_changelog_branch() + subprocess.check_call(("git", "checkout", changelog_branch)) + else: + changelog_branch = f"V3/changelogs/{version}" + subprocess.check_call(("git", "fetch", GH_URL)) + try: + subprocess.check_call(("git", "checkout", "-b", changelog_branch, "FETCH_HEAD")) + except subprocess.CalledProcessError: + rich.print() + if click.confirm( + f"It seems that {changelog_branch} branch already exists, do you want to use it?" + ): + subprocess.check_call(("git", "checkout", changelog_branch)) + elif not click.confirm("Do you want to use a different branch?"): + raise click.ClickException("Can't continue without a changelog branch...") + elif click.confirm("Do you want to create a new branch?"): + while True: + changelog_branch = click.prompt("Input the name of the new branch") + try: + subprocess.check_call( + ("git", "checkout", "-b", changelog_branch, "FETCH_HEAD") + ) + except subprocess.CalledProcessError: + continue + else: + break + else: + while True: + changelog_branch = click.prompt("Input the name of the branch to check out") + try: + subprocess.check_call(("git", "checkout", changelog_branch)) + except subprocess.CalledProcessError: + continue + else: + break + + set_changelog_branch(changelog_branch) + set_release_stage(ReleaseStage.CHANGELOG_BRANCH_EXISTS) + + if get_release_stage() < ReleaseStage.CHANGELOG_COMMITTED: + rich.print( + "\n:pencil: At this point, you should have an up-to-date milestone" + " containing all PRs that are contained in this release. If you're not sure if all PRs" + " are properly assigned, you might find output of the option 1 below helpful." + ) + while True: + rich.print( + Markdown( + "1. Show unreleased commits without a milestone.\n" + "2. View detailed information about all issues and PRs in the milestone.\n" + "3. Get contributor list formatted for the changelog.\n" + "4. Continue." + ) + ) + option = click.prompt("Select option", type=click.Choice(["1", "2", "3", "4"])) + if option == "1": + show_unreleased_commits(version, get_base_branch()) + continue + if option == "2": + view_milestone_issues(version) + continue + if option == "3": + get_contributors(version) + continue + if option == "4": + break + + commands = [ + ("git", "add", "."), + ("git", "commit", "-m", f"Red {version} - Changelog"), + ("git", "push", "-u", GH_URL, f"{changelog_branch}:{changelog_branch}"), + ] + print( + "Do you want to commit everything from repo's working tree and push it?" + " The following commands will run:" + ) + for command in commands: + print(shlex.join(command)) + if click.confirm("Do you want to run above commands to open a new changelog PR?"): + subprocess.check_call(commands[0]) + subprocess.check_call(commands[1]) + set_release_stage(ReleaseStage.CHANGELOG_COMMITTED) + else: + print("Okay, please open a changelog PR manually then.") + if get_release_stage() is ReleaseStage.CHANGELOG_COMMITTED: + subprocess.check_call(commands[2]) + pr_url = ( + f"{GH_URL}/compare/V3/develop...{changelog_branch}" + f"?expand=1&milestone={version}&labels=Type:+Feature" + ) + print(f"Create new PR: {pr_url}") + webbrowser.open_new_tab(pr_url) + if get_release_stage() <= ReleaseStage.CHANGELOG_PR_OPENED: + set_release_stage(ReleaseStage.CHANGELOG_PR_OPENED) + pause() + if get_release_stage() <= ReleaseStage.CHANGELOG_CREATED: + base_branch = get_base_branch() + try: + subprocess.check_call(("git", "checkout", base_branch)) + except subprocess.CalledProcessError: + rich.print( + f"Can't check out {base_branch} branch." + " Resolve the issue and check out that branch before proceeding." + ) + pause() + set_release_stage(ReleaseStage.CHANGELOG_CREATED) + + +def review_changelog(release_type: ReleaseType, version: str) -> None: + rich.print(Markdown("# Step 4: Review/wait for review of the changelog PR")) + if get_release_stage() >= ReleaseStage.CHANGELOG_REVIEWED: + rich.print(":white_check_mark: Already done!") + return + if release_type is ReleaseType.HOTFIX: + rich.print( + "Hotfix releases [bold]need to[/] contain a changelog.\n" + "It can be limited to a short description of what the hotfix release fixes," + " for example see:" + " [link=https://docs.discord.red/en/stable/changelog.html#redbot-3-4-12-2021-06-17]" + "Red 3.4.12 changelog" + "[/]" + ) + else: + rich.print( + Markdown( + "- Add (or ask PR author to add) any missing entries" + " based on the release’s milestone.\n" + "- Update the contributors list in the changelog using contributors list for" + " the milestone that you can generate using the option 1 below\n" + "- Merge the PR once it’s ready.\n" + ) + ) + + pause() + set_release_stage(ReleaseStage.CHANGELOG_REVIEWED) + + +def run_prepare_release_workflow(release_type: ReleaseType, version: str) -> None: + rich.print(Markdown("# Step 5: Run 'Prepare Release' workflow")) + if get_release_stage() >= ReleaseStage.PREPARE_RELEASE_RAN: + rich.print(":white_check_mark: Already done!") + + base_branch = get_base_branch() + if get_release_stage() < ReleaseStage.PREPARE_RELEASE_SPAWNED: + rich.print( + Markdown( + "## Release details\n" + f"- Version number: {version}\n" + f"- Branch to release from: {base_branch}\n\n" + "**Please verify the correctness of above information before confirming.**" + ) + ) + if not click.confirm("Is the above information correct?"): + raise click.ClickException( + "Please check out the branch that you want to release from" + " and start this program again." + ) + rich.print( + ":information_source-emoji: This step only takes care of automatically creating some PRs," + " it won’t release anything, don’t worry!" + ) + if not click.confirm("Do you want to run the 'Prepare Release' workflow?"): + raise click.ClickException( + "Run this command again once you're ready to run this workflow." + ) + + run_list_command = ( + "gh", + "run", + "list", + "--limit=1", + "--json=databaseId,number", + "--workflow=prepare_release.yml", + "--branch", + base_branch, + ) + previous_run = json.loads(subprocess.check_output(run_list_command, text=True))[0][ + "number" + ] + subprocess.check_call( + ("gh", "workflow", "run", "prepare_release.yml", "--ref", base_branch) + ) + set_release_stage(ReleaseStage.PREPARE_RELEASE_SPAWNED) + if get_release_stage() < ReleaseStage.PREPARE_RELEASE_RAN: + rich.print("Waiting for GitHub Actions workflow to show...") + time.sleep(2) + while True: + data = json.loads(subprocess.check_output(run_list_command, text=True))[0] + if data["number"] > previous_run: + run_id = data["databaseId"] + break + time.sleep(5) + + subprocess.check_call(("gh", "run", "watch", run_id)) + rich.print("The automated pull requests have been created.\n") + set_release_stage(ReleaseStage.PREPARE_RELEASE_RAN) + rich.print(Markdown("# Step 6: Merge the automatically created PRs")) + if get_release_stage() >= ReleaseStage.AUTOMATED_PULLS_MERGED: + rich.print(":white_check_mark: Already done!") + return + output = subprocess.check_output( + ( + "gh", + "pr", + "list", + "--json=number,title,state", + "--template", + "{{if .}}" + '{{tablerow "NUMBER" "STATE" "TITLE"}}{{range .}}' + '{{tablerow (printf "#%v" .number) .state .title}}{{end}}{{tablerender}}' + "{{end}}", + "--limit=999", + "--search", + f'milestone:{version} label:"Automated PR"', + ), + text=True, + ) + print(linkify_issue_refs_cli(output)) + pause() + set_release_stage(ReleaseStage.AUTOMATED_PULLS_MERGED) + + +def create_short_lived_branch(release_type: ReleaseType, version: str) -> None: + rich.print(Markdown("# Step 7: Create a short-lived release branch")) + if get_release_stage() >= ReleaseStage.SHORT_LIVED_BRANCH_CREATED: + rich.print(":white_check_mark: Already done!") + return + if release_type in (ReleaseType.BREAKING, ReleaseType.STANDARD): + rich.print(f"This does not apply to {release_type}s.") + set_release_stage(ReleaseStage.SHORT_LIVED_BRANCH_CREATED) + return + if release_type is ReleaseType.HOTFIX and click.confirm( + "Are you releasing from the long-lived branch (V3/develop or 3.x)?" + ): + rich.print(f"This does not apply to {release_type}s released from a long-lived branch.") + set_release_stage(ReleaseStage.SHORT_LIVED_BRANCH_CREATED) + return + + rich.print( + Markdown( + f"- Create a branch named V3/release/{version} based off a tag of previous version.\n" + " This can be done with the command:\n" + " ```\n" + f" git checkout -b V3/release/{version} PREVIOUS_VERSION\n" + " ```\n" + "- Cherry-pick the relevant changes, the changelog, the automated PRs, and the version bump.\n" + "- Push the branch to upstream repository (Cog-Creators/Red-DiscordBot)\n" + " This can be done with the command:\n" + " ```\n" + f" git push -u {GH_URL} V3/release/{version}\n" + " ```" + ) + ) + pause() + set_release_stage(ReleaseStage.SHORT_LIVED_BRANCH_CREATED) + + +STEPS = ( + ensure_no_release_blockers, + check_state_of_open_pulls, + create_changelog, + review_changelog, + run_prepare_release_workflow, + create_short_lived_branch, +) + + +@cli.command(name="unreleased") +@click.argument("version") +@click.argument("base_branch") +def cli_unreleased(version: str, base_branch: str) -> int: + show_unreleased_commits(version, base_branch) + + +def show_unreleased_commits(version: str, base_branch: str) -> int: + token = get_github_token() + + resp = requests.post( + "https://api.github.com/graphql", + json={"query": GET_ALL_TAG_COMMITS_QUERY}, + headers={"Authorization": f"Bearer {token}"}, + ) + json = resp.json() + tag_commits = { + node["target"]["oid"]: node["name"] for node in json["data"]["repository"]["refs"]["nodes"] + } + + after = None + has_next_page = True + commits_without_pr: List[str] = [] + commits_with_no_milestone: List[str] = [] + commits_with_different_milestone: Dict[str, List[str]] = defaultdict(list) + while has_next_page: + resp = requests.post( + "https://api.github.com/graphql", + json={ + "query": GET_COMMIT_HISTORY_QUERY, + "variables": { + "after": after, + "refQualifiedName": f"refs/heads/{base_branch}", + }, + }, + headers={"Authorization": f"Bearer {token}"}, + ) + json = resp.json() + data = json["data"] + history = data["repository"]["ref"]["target"]["history"] + + for node in history["nodes"]: + maybe_tag_name = tag_commits.get(node["oid"]) + if maybe_tag_name is not None: + has_next_page = False + break + commits: Optional[List[str]] = None + associated_pr = next(iter(node["associatedPullRequests"]["nodes"]), None) + if associated_pr is None: + commits = commits_without_pr + elif (milestone_data := associated_pr["milestone"]) is None: + commits = commits_with_no_milestone + elif milestone_data["title"] != version: + commits = commits_with_different_milestone[milestone_data["title"]] + if commits is not None: + commits.append( + f"- [{node['abbreviatedOid']}]" + f"({GH_URL}/commit/{node['oid']})" + f" - {linkify_issue_refs_md(node['messageHeadline'])}" + ) + else: + page_info = history["pageInfo"] + after = page_info["endCursor"] + has_next_page = page_info["hasNextPage"] + + parts = [] + parts.append(f"## Unreleased commits without {version} milestone") + if commits_without_pr: + parts.append("\n### Commits without associated PR\n") + parts.append("\n".join(commits_without_pr)) + if commits_with_no_milestone: + parts.append("\n### Commits with no milestone\n") + parts.append("\n".join(commits_with_no_milestone)) + if commits_with_different_milestone: + parts.append("\n### Commits with different milestone\n") + for milestone_title, commits in commits_with_different_milestone.items(): + parts.append(f"\n#### {milestone_title}\n") + parts.extend(commits) + + rich.print(Markdown("\n".join(parts))) + + +@cli.command(name="milestone") +@click.argument("version") +def cli_milestone(version: str) -> None: + view_milestone_issues(version) + + +def view_milestone_issues(version: str) -> None: + issue_views = [] + for issue_type in ("pr", "issue"): + for number in subprocess.check_output( + ( + "gh", + issue_type, + "list", + "--json=number", + "--jq=.[].number", + "--limit=999", + "--state=all", + "--search", + f"milestone:{version}", + ), + text=True, + ).splitlines(): + view = linkify_issue_refs_cli( + subprocess.check_output( + ("gh", issue_type, "view", number), env=GH_FORCE_TTY_ENV, text=True + ) + ) + if not issue_views: + # print one issue while we're waiting to fetch all + print(view) + issue_views.append(view) + + pydoc.pager("\n---\n\n".join(issue_views)) + + +@cli.command(name="contributors") +@click.argument("version") +@click.option("--show-not-merged", is_flag=True, default=False) +def cli_contributors(version: str, *, show_not_merged: bool = False) -> None: + get_contributors(version, show_not_merged=show_not_merged) + + +def get_contributors(version: str, *, show_not_merged: bool = False) -> None: + print( + ", ".join( + f":ghuser:`{username}`" + for username in _get_contributors(version, show_not_merged=show_not_merged) + ) + ) + + +def _get_contributors(version: str, *, show_not_merged: bool = False) -> List[str]: + after = None + has_next_page = True + authors = {} + reviewers = {} + token = get_github_token() + states = ["MERGED"] + if show_not_merged: + states.append("OPEN") + while has_next_page: + resp = requests.post( + "https://api.github.com/graphql", + json={ + "query": GET_MILESTONE_CONTRIBUTORS_QUERY, + "variables": { + "milestone": version, + "after": after, + "states": states, + }, + }, + headers={"Authorization": f"Bearer {token}"}, + ) + json = resp.json() + try: + milestone_data = json["data"]["repository"]["milestones"]["nodes"][0] + except IndexError: + raise click.ClickException("Given milestone couldn't have been found.") + milestone_title = milestone_data["title"] + pull_requests = milestone_data["pullRequests"] + nodes = pull_requests["nodes"] + for pr_node in nodes: + pr_info = (pr_node["number"], pr_node["title"]) + pr_author = pr_node["author"]["login"] + authors.setdefault(pr_author, []).append(pr_info) + reviews = pr_node["latestOpinionatedReviews"]["nodes"] + for review_node in reviews: + review_author = review_node["author"]["login"] + reviewers.setdefault(review_author, []).append(pr_info) + + page_info = pull_requests["pageInfo"] + after = page_info["endCursor"] + has_next_page = page_info["hasNextPage"] + + return sorted(authors.keys() | reviewers.keys(), key=lambda t: t[0].lower()) + + +if __name__ == "__main__": + raise SystemExit(cli())