airflow assign_cherry_picked_prs_with_milestone 源码

  • 2022-10-20
  • 浏览 (335)

airflow assign_cherry_picked_prs_with_milestone 代码

文件路径:/dev/assign_cherry_picked_prs_with_milestone.py

#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations

import logging
import os
import re
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Any, NamedTuple, cast

import rich_click as click
from github import Github, UnknownObjectException
from github.Milestone import Milestone
from github.PullRequest import PullRequest
from github.Repository import Repository
from rich.console import Console
from rich.prompt import Confirm, Prompt

CHANGELOG_SKIP_LABEL = "changelog:skip"

TYPE_DOC_ONLY_LABEL = "type:doc-only"

logger = logging.getLogger(__name__)

console = Console(width=400, color_system="standard")

MY_DIR_PATH = os.path.dirname(__file__)
SOURCE_DIR_PATH = os.path.abspath(os.path.join(MY_DIR_PATH, os.pardir))
PR_PATTERN = re.compile(r".*\(#([0-9]+)\)")
ISSUE_MATCH_IN_BODY = re.compile(r" #([0-9]+)[^0-9]")

CHANGELOG_CHANGES_FILE = "changelog-changes.txt"
DOC_ONLY_CHANGES_FILE = "doc-only-changes.txt"
EXCLUDED_CHANGES_FILE = "excluded-changes.txt"


@click.group(context_settings={'help_option_names': ['-h', '--help'], 'max_content_width': 500})
def cli():
    ...


option_verbose = click.option(
    "--verbose",
    is_flag=True,
    help="Print verbose information about performed steps",
)

option_assume_yes = click.option(
    "--assume-yes",
    is_flag=True,
    help="Assume yes answer to question",
)


option_previous_release = click.option(
    "--previous-release",
    type=str,
    required=True,
    help="commit reference (for example hash or tag) of the previous release.",
)

option_current_release = click.option(
    "--current-release",
    type=str,
    required=True,
    help="commit reference (for example hash or tag) of the current release.",
)

option_github_token = click.option(
    "--github-token",
    type=str,
    required=True,
    help=textwrap.dedent(
        """
        GitHub token used to authenticate.
        You can set omit it if you have GITHUB_TOKEN env variable set
        Can be generated with:
        https://github.com/settings/tokens/new?description=Read%20Write%20isssues&scopes=repo"""
    ),
    envvar='GITHUB_TOKEN',
)

option_limit_pr_count = click.option(
    "--limit-pr-count",
    type=int,
    default=None,
    help="Limit PR count processes (useful for testing small subset of PRs).",
)

option_dry_run = click.option(
    "--dry-run",
    is_flag=True,
    help="Do not make any changes, just show what would have been done",
)

option_skip_assigned = click.option(
    "--skip-assigned",
    is_flag=True,
    help="Skip PRs already correctly assigned to the right milestone",
)

option_milestone_number = click.option(
    "--milestone-number",
    type=int,
    required=True,
    help="Milestone number to set. See https://github.com/apache/airflow/milestones to find milestone id",
)

option_print_summary = click.option(
    "--print-summary",
    is_flag=True,
    help="Produce summary of the changes cherry-picked in the file specified. Implies --skip-assigned",
)

option_output_folder = click.option(
    "--output-folder",
    type=str,
    help="Folder where files with commit hashes will be store. Implies --print-summary and --skip-assigned",
)


def render_template(
    template_name: str,
    context: dict[str, Any],
    autoescape: bool = False,
    keep_trailing_newline: bool = False,
) -> str:
    """
    Renders template based on its name. Reads the template from <name>.jinja2 in current dir.
    :param template_name: name of the template to use
    :param context: Jinja2 context
    :param autoescape: Whether to autoescape HTML
    :param keep_trailing_newline: Whether to keep the newline in rendered output
    :return: rendered template
    """
    import jinja2

    template_loader = jinja2.FileSystemLoader(searchpath=MY_DIR_PATH)
    template_env = jinja2.Environment(
        loader=template_loader,
        undefined=jinja2.StrictUndefined,
        autoescape=autoescape,
        keep_trailing_newline=keep_trailing_newline,
    )
    template = template_env.get_template(f"{template_name}.jinja2")
    content: str = template.render(context)
    return content


def get_git_log_command(
    verbose: bool, from_commit: str | None = None, to_commit: str | None = None
) -> list[str]:
    """
    Get git command to run for the current repo from the current folder (which is the package folder).
    :param verbose: whether to print verbose info while getting the command
    :param from_commit: if present - base commit from which to start the log from
    :param to_commit: if present - final commit which should be the start of the log
    :return: git command to run
    """
    git_cmd = [
        "git",
        "log",
        "--pretty=format:%H %h %cd %s",
        "--date=short",
    ]
    if from_commit and to_commit:
        git_cmd.append(f"{from_commit}...{to_commit}")
    elif from_commit:
        git_cmd.append(from_commit)
    git_cmd.extend(['--', '.'])
    if verbose:
        console.print(f"Command to run: '{' '.join(git_cmd)}'")
    return git_cmd


class Change(NamedTuple):
    """Stores details about commits"""

    full_hash: str
    short_hash: str
    date: str
    message: str
    message_without_backticks: str
    pr: int | None


def get_change_from_line(line: str) -> Change:
    split_line = line.split(" ", maxsplit=3)
    message = split_line[3]
    pr = None
    pr_match = PR_PATTERN.match(message)
    if pr_match:
        pr = pr_match.group(1)
    return Change(
        full_hash=split_line[0],
        short_hash=split_line[1],
        date=split_line[2],
        message=message,
        message_without_backticks=message.replace("`", "'").replace("&#39;", "'").replace('&amp;', "&"),
        pr=int(pr) if pr else None,
    )


def get_changes(verbose: bool, previous_release: str, current_release: str) -> list[Change]:
    change_strings = subprocess.check_output(
        get_git_log_command(verbose, from_commit=previous_release, to_commit=current_release),
        cwd=SOURCE_DIR_PATH,
        text=True,
    )
    return [get_change_from_line(line) for line in change_strings.split("\n")]


def update_milestone(r: Repository, pr: PullRequest, m: Milestone):
    # PR in GitHub API does not have a way to update milestone. It should be opened as issue,
    # and then it can be updated ¯\_(ツ)_/¯
    r.get_issue(pr.number).edit(milestone=m)


@cli.command()
@option_github_token
@option_previous_release
@option_current_release
@option_verbose
@option_limit_pr_count
@option_dry_run
@option_milestone_number
@option_skip_assigned
@option_print_summary
@option_assume_yes
@option_output_folder
def assign_prs(
    github_token: str,
    previous_release: str,
    current_release: str,
    verbose: bool,
    limit_pr_count: int | None,
    dry_run: bool,
    milestone_number: int,
    skip_assigned: bool,
    print_summary: bool,
    assume_yes: bool,
    output_folder: str,
):
    changes = get_changes(verbose, previous_release, current_release)
    changes = list(filter(lambda change: change.pr is not None, changes))
    prs = [change.pr for change in changes]

    g = Github(github_token)
    repo = g.get_repo("apache/airflow")

    if output_folder and not print_summary:
        console.print("\n[yellow]Implying --print-summary as output folder is enabled[/]\n")
        print_summary = True
    if print_summary and not skip_assigned:
        console.print("\n[yellow]Implying --skip-assigned as summary report is enabled[/]\n")
        skip_assigned = True
    milestone = repo.get_milestone(milestone_number)
    count_prs = len(prs)
    if limit_pr_count:
        count_prs = limit_pr_count
    console.print(f"\n[green]Applying Milestone: {milestone.title} to {count_prs} merged PRs[/]\n")
    if dry_run:
        console.print("[yellow]Dry run mode![/]\n")
    else:
        if not assume_yes and not Confirm.ask("Is this OK?"):
            sys.exit(1)

    doc_only_label = repo.get_label(TYPE_DOC_ONLY_LABEL)
    changelog_skip_label = repo.get_label(CHANGELOG_SKIP_LABEL)
    changelog_changes: list[Change] = []
    doc_only_changes: list[Change] = []
    excluded_changes: list[Change] = []
    for i in range(count_prs):
        pr_number = prs[i]
        if pr_number is None:
            # Should not happen but MyPy is not happy
            continue
        console.print('-' * 80)
        console.print(
            f"\n >>>> Retrieving PR#{pr_number}: https://github.com/apache/airflow/pull/{pr_number}"
        )
        pr: PullRequest
        try:
            pr = repo.get_pull(pr_number)
        except UnknownObjectException:
            # Fallback to issue if PR not found
            try:
                # PR has almost the same fields as Issue
                pr = cast(PullRequest, repo.get_issue(pr_number))
            except UnknownObjectException:
                console.print(f"[red]The PR #{pr_number} could not be found[/]")
                continue
        console.print(f"\nPR:{pr_number}: {pr.title}\n")
        label_names = [label.name for label in pr.labels]
        already_assigned_milestone_number = pr.milestone.number if pr.milestone else None
        if already_assigned_milestone_number == milestone.number:
            console.print(
                f"[green]The PR #{pr_number} is already "
                f"assigned to the milestone: {pr.milestone.title}[/]. Labels: {label_names}"
            )
            if TYPE_DOC_ONLY_LABEL in label_names:
                console.print("[yellow]It will be classified as doc-only change[/]\n")
                if skip_assigned:
                    doc_only_changes.append(changes[i])
            elif CHANGELOG_SKIP_LABEL in label_names:
                console.print("[yellow]It will be excluded from changelog[/]\n")
                if skip_assigned:
                    excluded_changes.append(changes[i])
            else:
                console.print("[green]The change will be included in changelog[/]\n")
                if skip_assigned:
                    changelog_changes.append(changes[i])
            if skip_assigned:
                continue
        elif already_assigned_milestone_number is not None:
            console.print(
                f"[yellow]The PR #{pr_number} is already "
                f"assigned to another milestone: {pr.milestone.title}[/]. Labels: {label_names}"
            )
        # Ignore doc-only and skipped PRs
        console.print(f"Marking the PR #{pr_number} as {milestone.title}")
        chosen_option = Prompt.ask(
            "Choose action:",
            choices=["a", "add", "d", "doc", "e", "exclude", "s", "skip", "q", "quit"],
            default="skip",
        ).lower()
        if chosen_option in ("add", "a"):
            console.print(f"Adding the PR #{pr_number} to {milestone.title}")
            if not dry_run:
                update_milestone(repo, pr, milestone)
            if skip_assigned:
                changelog_changes.append(changes[i])
        elif chosen_option in ("doc", "d"):
            console.print(f"Applying the label {doc_only_label} the PR #{pr_number}")
            if not dry_run:
                pr.add_to_labels(doc_only_label)
                update_milestone(repo, pr, milestone)
            if skip_assigned:
                doc_only_changes.append(changes[i])
        elif chosen_option in ("exclude", "e"):
            console.print(f"Applying the label {changelog_skip_label} the PR #{pr_number}")
            if not dry_run:
                pr.add_to_labels(changelog_skip_label)
                update_milestone(repo, pr, milestone)
            if skip_assigned:
                excluded_changes.append(changes[i])
        elif chosen_option in ("skip", "s"):
            console.print(f"Skipping the PR #{pr_number}")
        elif chosen_option in ("quit", "q"):
            sys.exit(2)

    if print_summary:
        context = {
            "changelog_changes": changelog_changes,
            "excluded_changes": excluded_changes,
            "doc_only_changes": doc_only_changes,
            "previous_release": previous_release,
            "current_release": current_release,
        }
        console.print(render_template("CHERRY_PICK_SUMMARY.txt", context=context))

    if output_folder:

        def write_commits(type: str, path: Path, changes_to_write: list[Change]):
            path.write_text("\n".join(change.short_hash for change in changes_to_write) + "\n")
            console.print(f"\n{type} commits written in {path}")

        write_commits("Changelog", Path(output_folder) / CHANGELOG_CHANGES_FILE, changelog_changes)
        write_commits("Doc only", Path(output_folder) / DOC_ONLY_CHANGES_FILE, doc_only_changes)
        write_commits("Excluded", Path(output_folder) / EXCLUDED_CHANGES_FILE, excluded_changes)
        console.print("\n")


if __name__ == "__main__":
    cli()

相关信息

airflow 源码目录

相关文章

airflow init 源码

airflow check_files 源码

airflow prepare_bulk_issues 源码

airflow prepare_release_issue 源码

airflow retag_docker_images 源码

airflow send_email 源码

airflow validate_version_added_fields_in_config 源码

0  赞