diff --git a/mergify_cli/__init__.py b/mergify_cli/__init__.py index d531b2f..32f9c87 100755 --- a/mergify_cli/__init__.py +++ b/mergify_cli/__init__.py @@ -15,1288 +15,12 @@ from __future__ import annotations -import argparse -import asyncio -import dataclasses import importlib.metadata -import json -import os -import pathlib -import re -import shutil -import sys -import typing -from urllib import parse -import aiofiles -import httpx import rich import rich.console -from mergify_cli import github_types - -VERSION = importlib.metadata.version("mergify-cli") - -CHANGEID_RE = re.compile(r"Change-Id: (I[0-9a-z]{40})") -DEPENDS_ON_RE = re.compile(r"Depends-On: (#[0-9]*)") console = rich.console.Console(log_path=False, log_time=False) -DEBUG = False -TMP_STACK_BRANCH = "mergify-cli-tmp" - - -async def check_for_status(response: httpx.Response) -> None: - if response.status_code < 400: - return - - if response.status_code < 500: - await response.aread() - data = response.json() - console.print(f"url: {response.request.url}", style="red") - console.print(f"data: {response.request.content.decode()}", style="red") - console.print( - f"HTTPError {response.status_code}: {data['message']}", - style="red", - ) - if "errors" in data: - console.print( - "\n".join(f"* {e.get('message') or e}" for e in data["errors"]), - style="red", - ) - sys.exit(1) - - response.raise_for_status() - - -@dataclasses.dataclass -class CommandError(Exception): - command_args: tuple[str, ...] - returncode: int | None - stdout: bytes - - def __str__(self) -> str: - return f"failed to run `{' '.join(self.command_args)}`: {self.stdout.decode()}" - - -async def _run_command(*args: str) -> str: - if DEBUG: - console.print(f"[purple]DEBUG: running: git {' '.join(args)} [/]") - proc = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - stdout, _ = await proc.communicate() - if proc.returncode != 0: - raise CommandError(args, proc.returncode, stdout) - return stdout.decode().strip() - - -async def git(*args: str) -> str: - return await _run_command("git", *args) - - -def get_slug(url: str) -> tuple[str, str]: - parsed = parse.urlparse(url) - if not parsed.netloc: - # Probably ssh - _, _, path = parsed.path.partition(":") - else: - path = parsed.path[1:].rstrip("/") - - user, repo = path.split("/", 1) - repo = repo.removesuffix(".git") - return user, repo - - -async def stack_setup(_: argparse.Namespace) -> None: - hooks_dir = pathlib.Path(await git("rev-parse", "--git-path", "hooks")) - installed_hook_file = hooks_dir / "commit-msg" - - new_hook_file = str( - importlib.resources.files(__package__).joinpath("hooks/commit-msg"), - ) - - if installed_hook_file.exists(): - async with aiofiles.open(installed_hook_file) as f: - data_installed = await f.read() - async with aiofiles.open(new_hook_file) as f: - data_new = await f.read() - if data_installed == data_new: - console.log("Git commit-msg hook is up to date") - else: - console.print( - f"error: {installed_hook_file} differ from mergify_cli hook", - style="red", - ) - sys.exit(1) - - else: - console.log("Installation of git commit-msg hook") - shutil.copy(new_hook_file, installed_hook_file) - installed_hook_file.chmod(0o755) - - -ChangeId = typing.NewType("ChangeId", str) -RemoteChanges = typing.NewType( - "RemoteChanges", - dict[ChangeId, github_types.PullRequest], -) - - -class PullRequestNotExistError(Exception): - pass - - -@dataclasses.dataclass -class Change: - id: ChangeId - pull: github_types.PullRequest | None - - @property - def pull_head_sha(self) -> str: - if self.pull is None: - raise PullRequestNotExistError - return self.pull["head"]["sha"] - - @property - def pull_short_head_sha(self) -> str: - return self.pull_head_sha[:7] - - -ActionT = typing.Literal[ - "skip-merged", - "skip-next-only", - "skip-create", - "skip-up-to-date", - "create", - "update", -] - - -@dataclasses.dataclass -class LocalChange(Change): - commit_sha: str - title: str - message: str - base_branch: str - dest_branch: str - action: ActionT - - @property - def commit_short_sha(self) -> str: - return self.commit_sha[:7] - - -@dataclasses.dataclass -class OrphanChange(Change): - pass - - -@dataclasses.dataclass -class Changes: - stack_prefix: str - locals: list[LocalChange] = dataclasses.field(default_factory=list) - orphans: list[OrphanChange] = dataclasses.field(default_factory=list) - - -async def get_changes( # noqa: PLR0913,PLR0917 - base_commit_sha: str, - stack_prefix: str, - base_branch: str, - dest_branch: str, - remote_changes: RemoteChanges, - only_update_existing_pulls: bool, - next_only: bool, -) -> Changes: - commits = ( - commit - for commit in reversed( - ( - await git("log", "--format=%H", f"{base_commit_sha}..{dest_branch}") - ).split( - "\n", - ), - ) - if commit - ) - changes = Changes(stack_prefix) - remaining_remote_changes = remote_changes.copy() - - for idx, commit in enumerate(commits): - message = await git("log", "-1", "--format=%b", commit) - title = await git("log", "-1", "--format=%s", commit) - - changeids = CHANGEID_RE.findall(message) - if not changeids: - console.print( - f"`Change-Id:` line is missing on commit {commit}", - style="red", - ) - console.print( - "Did you run `mergify stack --setup` for this repository?", - ) - sys.exit(1) - - changeid = ChangeId(changeids[-1]) - pull = remaining_remote_changes.pop(changeid, None) - - action: ActionT - if next_only and idx > 0: - action = "skip-next-only" - elif pull is None: - if only_update_existing_pulls: - action = "skip-create" - action = "create" - elif pull["merged_at"]: - action = "skip-merged" - elif pull["head"]["sha"] == commit: - action = "skip-up-to-date" - else: - action = "update" - - changes.locals.append( - LocalChange( - changeid, - pull, - commit, - title, - message, - changes.locals[-1].dest_branch if changes.locals else base_branch, - f"{stack_prefix}/{changeid}", - action, - ), - ) - - for changeid, pull in remaining_remote_changes.items(): - if pull["state"] == "open": - changes.orphans.append(OrphanChange(changeid, pull)) - - return changes - - -def get_log_from_local_change( - change: LocalChange, - dry_run: bool, - create_as_draft: bool, -) -> str: - url = f"<{change.dest_branch}>" if change.pull is None else change.pull["html_url"] - - flags: str = "" - if change.pull and change.pull["draft"]: - flags += " [yellow](draft)[/]" - - if change.action == "create": - color = "yellow" if dry_run else "blue" - action = "to create" if dry_run else "created" - commit_info = change.commit_short_sha - if create_as_draft: - flags += " [yellow](draft)[/]" - - elif change.action == "update": - color = "yellow" if dry_run else "blue" - action = "to update" if dry_run else "updated" - commit_info = f"{change.pull_short_head_sha} -> {change.commit_short_sha}" - - elif change.action == "skip-create": - color = "grey" - action = "skip, --only-update-existing-pulls" - commit_info = change.commit_short_sha - - elif change.action == "skip-merged": - color = "purple" - action = "merged" - flags += " [purple](merged)[/]" - commit_info = ( - f"{change.pull['merge_commit_sha'][7:]}" - if change.pull - and change.pull["merged_at"] - and change.pull["merge_commit_sha"] - else change.commit_short_sha - ) - - elif change.action == "skip-next-only": - color = "grey" - action = "skip, --next-only" - commit_info = change.commit_short_sha - - elif change.action == "skip-up-to-date": - color = "grey" - action = "up-to-date" - commit_info = change.commit_short_sha - - else: - # NOTE: we don't want to miss any action - msg = f"Unhandled action: {change.action}" # type: ignore[unreachable] - raise RuntimeError(msg) - - return f"* [{color}]\\[{action}][/] '[red]{commit_info}[/] - [b]{change.title}[/]{flags} {url}" - - -def get_log_from_orphan_change(change: OrphanChange, dry_run: bool) -> str: - action = "to delete" if dry_run else "deleted" - title = change.pull["title"] if change.pull else "" - url = change.pull["html_url"] if change.pull else "" - sha = change.pull["head"]["sha"][7:] if change.pull else "" - return f"* [red]\\[{action}][/] '[red]{sha}[/] - [b]{title}[/] {url}" - - -def display_changes_plan( - changes: Changes, - create_as_draft: bool, -) -> None: - for change in changes.locals: - console.log( - get_log_from_local_change( - change, - dry_run=True, - create_as_draft=create_as_draft, - ), - ) - - for orphan in changes.orphans: - console.log(get_log_from_orphan_change(orphan, dry_run=True)) - - -async def create_or_update_comments( - client: httpx.AsyncClient, - user: str, - repo: str, - pulls: list[github_types.PullRequest], -) -> None: - stack_comment = StackComment(pulls) - - for pull in pulls: - if pull["merged_at"]: - continue - - new_body = stack_comment.body(pull) - - r = await client.get(f"/repos/{user}/{repo}/issues/{pull['number']}/comments") - comments = typing.cast(list[github_types.Comment], r.json()) - for comment in comments: - if StackComment.is_stack_comment(comment): - if comment["body"] != new_body: - await client.patch(comment["url"], json={"body": new_body}) - break - else: - # NOTE(charly): dont't create a stack comment if there is only one - # pull, it's not a stack - if len(pulls) == 1: - continue - - await client.post( - f"/repos/{user}/{repo}/issues/{pull['number']}/comments", - json={"body": new_body}, - ) - - -@dataclasses.dataclass -class StackComment: - pulls: list[github_types.PullRequest] - - STACK_COMMENT_FIRST_LINE = "This pull request is part of a stack:\n" - - def body(self, current_pull: github_types.PullRequest) -> str: - body = self.STACK_COMMENT_FIRST_LINE - - for pull in self.pulls: - body += f"1. {pull['title']} ([#{pull['number']}]({pull['html_url']}))" - if pull == current_pull: - body += " 👈" - body += "\n" - - return body - - @staticmethod - def is_stack_comment(comment: github_types.Comment) -> bool: - return comment["body"].startswith(StackComment.STACK_COMMENT_FIRST_LINE) - - -async def create_or_update_stack( # noqa: PLR0913,PLR0917 - client: httpx.AsyncClient, - user: str, - repo: str, - remote: str, - change: LocalChange, - depends_on: github_types.PullRequest | None, - create_as_draft: bool, - keep_pull_request_title_and_body: bool, -) -> github_types.PullRequest: - if change.pull is None: - status_message = f"* creating stacked branch `{change.dest_branch}` ({change.commit_short_sha})" - else: - status_message = f"* updating stacked branch `{change.dest_branch}` ({change.commit_short_sha}) - {change.pull['html_url'] if change.pull else ''})" - - with console.status(status_message): - await git("branch", TMP_STACK_BRANCH, change.commit_sha) - try: - await git( - "push", - "-f", - remote, - TMP_STACK_BRANCH + ":" + change.dest_branch, - ) - finally: - await git("branch", "-D", TMP_STACK_BRANCH) - - if change.action == "update": - if change.pull is None: - msg = "Can't update pull with change.pull unset" - raise RuntimeError(msg) - - with console.status( - f"* updating pull request `{change.title}` (#{change.pull['number']}) ({change.commit_short_sha})", - ): - pull_changes = { - "head": change.dest_branch, - "base": change.base_branch, - } - if keep_pull_request_title_and_body: - pull_changes.update( - { - "body": format_pull_description( - change.pull["body"] or "", - depends_on, - ), - }, - ) - else: - pull_changes.update( - { - "title": change.title, - "body": format_pull_description(change.message, depends_on), - }, - ) - - r = await client.patch( - f"/repos/{user}/{repo}/pulls/{change.pull['number']}", - json=pull_changes, - ) - return change.pull - - elif change.action == "create": - with console.status( - f"* creating stacked pull request `{change.title}` ({change.commit_short_sha})", - ): - r = await client.post( - f"/repos/{user}/{repo}/pulls", - json={ - "title": change.title, - "body": format_pull_description(change.message, depends_on), - "draft": create_as_draft, - "head": change.dest_branch, - "base": change.base_branch, - }, - ) - return typing.cast(github_types.PullRequest, r.json()) - - msg = f"Unhandled action: {change.action}" - raise RuntimeError(msg) - - -async def delete_stack( - client: httpx.AsyncClient, - user: str, - repo: str, - stack_prefix: str, - change: OrphanChange, -) -> None: - await client.delete( - f"/repos/{user}/{repo}/git/refs/heads/{stack_prefix}/{change.id}", - ) - console.log(get_log_from_orphan_change(change, dry_run=False)) - - -# NOTE: must be async for httpx -async def log_httpx_request(request: httpx.Request) -> None: # noqa: RUF029 - console.print( - f"[purple]DEBUG: request: {request.method} {request.url} - Waiting for response[/]", - ) - - -# NOTE: must be async for httpx -async def log_httpx_response(response: httpx.Response) -> None: - request = response.request - await response.aread() - elapsed = response.elapsed.total_seconds() - console.print( - f"[purple]DEBUG: response: {request.method} {request.url} - Status {response.status_code} - Elasped {elapsed} s[/]", - ) - - -async def git_get_branch_name() -> str: - return await git("rev-parse", "--abbrev-ref", "HEAD") - - -async def git_get_target_branch(branch: str) -> str: - return (await git("config", "--get", "branch." + branch + ".merge")).removeprefix( - "refs/heads/", - ) - - -async def git_get_target_remote(branch: str) -> str: - return await git("config", "--get", "branch." + branch + ".remote") - - -async def get_trunk() -> str: - try: - branch_name = await git_get_branch_name() - except CommandError: - console.print("error: can't get the current branch", style="red") - raise - try: - target_branch = await git_get_target_branch(branch_name) - except CommandError: - # It's possible this has not been set; ignore - console.print("error: can't get the remote target branch", style="red") - console.print( - f"Please set the target branch with `git branch {branch_name} --set-upstream-to=/", - style="red", - ) - raise - - try: - target_remote = await git_get_target_remote(branch_name) - except CommandError: - console.print( - f"error: can't get the target remote for branch {branch_name}", - style="red", - ) - raise - return f"{target_remote}/{target_branch}" - - -def trunk_type(trunk: str) -> tuple[str, str]: - result = trunk.split("/", maxsplit=1) - if len(result) != 2: - msg = "Trunk is invalid. It must be origin/branch-name [/]" - raise argparse.ArgumentTypeError(msg) - return result[0], result[1] - - -@dataclasses.dataclass -class LocalBranchInvalidError(Exception): - message: str - - -def check_local_branch(branch_name: str, branch_prefix: str) -> None: - if branch_name.startswith(branch_prefix) and re.search( - r"I[0-9a-z]{40}$", - branch_name, - ): - msg = "Local branch is a branch generated by Mergify CLI" - raise LocalBranchInvalidError(msg) - - -async def stack_edit(_: argparse.Namespace) -> None: - os.chdir(await git("rev-parse", "--show-toplevel")) - trunk = await get_trunk() - base = await git("merge-base", trunk, "HEAD") - os.execvp("git", ("git", "rebase", "-i", f"{base}^")) # noqa: S606 - - -async def get_remote_changes( - client: httpx.AsyncClient, - user: str, - repo: str, - stack_prefix: str, - author: str, -) -> RemoteChanges: - r_repo = await client.get(f"/repos/{user}/{repo}") - repository = r_repo.json() - - r = await client.get( - "/search/issues", - params={ - "repository_id": repository["id"], - "q": f"author:{author} is:pull-request head:{stack_prefix}", - "per_page": 100, - "sort": "updated", - }, - ) - - responses = await asyncio.gather( - *(client.get(item["pull_request"]["url"]) for item in r.json()["items"]), - ) - pulls = [typing.cast(github_types.PullRequest, r.json()) for r in responses] - - remote_changes = RemoteChanges({}) - for pull in pulls: - # Drop closed but not merged PR - if pull["state"] == "closed" and pull["merged_at"] is None: - continue - - changeid = ChangeId(pull["head"]["ref"].split("/")[-1]) - - if changeid in remote_changes: - other_pull = remote_changes[changeid] - if other_pull["state"] == "closed" and pull["state"] == "closed": - # Keep the more recent - pass - elif other_pull["state"] == "closed" and pull["state"] == "open": - remote_changes[changeid] = pull - elif other_pull["state"] == "opened": - msg = f"More than 1 pull found with this head: {pull['head']['ref']}" - raise RuntimeError(msg) - - else: - remote_changes[changeid] = pull - - return remote_changes - - -def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: - event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = { - "request": [], - "response": [check_for_status], - } - if DEBUG: - event_hooks["request"].insert(0, log_httpx_request) - event_hooks["response"].insert(0, log_httpx_response) - - return httpx.AsyncClient( - base_url=github_server, - headers={ - "Accept": "application/vnd.github.v3+json", - "User-Agent": f"mergify_cli/{VERSION}", - "Authorization": f"token {token}", - }, - event_hooks=event_hooks, - follow_redirects=True, - timeout=5.0, - ) - - -# TODO(charly): fix code to conform to linter (number of arguments, local -# variables, statements, positional arguments, branches) -async def stack_push( # noqa: PLR0912, PLR0913, PLR0915, PLR0917, PLR0914 - github_server: str, - token: str, - skip_rebase: bool, - next_only: bool, - branch_prefix: str | None, - dry_run: bool, - trunk: tuple[str, str], - create_as_draft: bool = False, - keep_pull_request_title_and_body: bool = False, - only_update_existing_pulls: bool = False, - author: str | None = None, -) -> None: - os.chdir(await git("rev-parse", "--show-toplevel")) - dest_branch = await git_get_branch_name() - - if author is None: - async with get_github_http_client(github_server, token) as client: - r_author = await client.get("/user") - author = r_author.json()["login"] - - if branch_prefix is None: - branch_prefix = await get_default_branch_prefix(author) - - try: - check_local_branch(branch_name=dest_branch, branch_prefix=branch_prefix) - except LocalBranchInvalidError as e: - console.log(f"[red] {e.message} [/]") - console.log( - "You should run `mergify stack` on the branch you created in the first place", - ) - sys.exit(1) - - remote, base_branch = trunk - - user, repo = get_slug(await git("config", "--get", f"remote.{remote}.url")) - - if base_branch == dest_branch: - console.log("[red] base branch and destination branch are the same [/]") - sys.exit(1) - - stack_prefix = f"{branch_prefix}/{dest_branch}" if branch_prefix else dest_branch - - if not dry_run: - if skip_rebase: - console.log(f"branch `{dest_branch}` rebase skipped (--skip-rebase)") - else: - with console.status( - f"Rebasing branch `{dest_branch}` on `{remote}/{base_branch}`...", - ): - await git("pull", "--rebase", remote, base_branch) - console.log(f"branch `{dest_branch}` rebased on `{remote}/{base_branch}`") - - base_commit_sha = await git("merge-base", "--fork-point", f"{remote}/{base_branch}") - if not base_commit_sha: - console.log( - f"Common commit between `{remote}/{base_branch}` and `{dest_branch}` branches not found", - style="red", - ) - sys.exit(1) - - event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = { - "request": [], - "response": [check_for_status], - } - if DEBUG: - event_hooks["request"].insert(0, log_httpx_request) - event_hooks["response"].insert(0, log_httpx_response) - - async with get_github_http_client(github_server, token) as client: - with console.status("Retrieving latest pushed stacks"): - remote_changes = await get_remote_changes( - client, - user, - repo, - stack_prefix, - author, - ) - - with console.status("Preparing stacked branches..."): - console.log("Stacked pull request plan:", style="green") - changes = await get_changes( - base_commit_sha, - stack_prefix, - base_branch, - dest_branch, - remote_changes, - only_update_existing_pulls, - next_only, - ) - - display_changes_plan( - changes, - create_as_draft, - ) - - if dry_run: - console.log("[orange]Finished (dry-run mode) :tada:[/]") - sys.exit(0) - - console.log("Updating and/or creating stacked pull requests:", style="green") - - pulls_to_comment: list[github_types.PullRequest] = [] - for change in changes.locals: - depends_on = pulls_to_comment[-1] if pulls_to_comment else None - - if change.action in {"create", "update"}: - pull = await create_or_update_stack( - client, - user, - repo, - remote, - change, - depends_on, - create_as_draft, - keep_pull_request_title_and_body, - ) - change.pull = pull - - if change.pull: - pulls_to_comment.append(change.pull) - - console.log( - get_log_from_local_change( - change, - dry_run=False, - create_as_draft=create_as_draft, - ), - ) - - with console.status("Updating comments..."): - await create_or_update_comments(client, user, repo, pulls_to_comment) - - console.log("[green]Comments updated") - - with console.status("Deleting unused branches..."): - if changes.orphans: - await asyncio.wait( - asyncio.create_task( - delete_stack(client, user, repo, stack_prefix, change), - ) - for change in changes.orphans - ) - - console.log("[green]Finished :tada:[/]") - - -def format_pull_description( - message: str, - depends_on: github_types.PullRequest | None, -) -> str: - depends_on_header = "" - if depends_on is not None: - depends_on_header = f"\n\nDepends-On: #{depends_on['number']}" - - message = CHANGEID_RE.sub("", message).rstrip("\n") - message = DEPENDS_ON_RE.sub("", message).rstrip("\n") - - return message + depends_on_header - - -def GitHubToken(v: str) -> str: # noqa: N802 - if not v: - raise ValueError - return v - - -async def get_default_github_server() -> str: - try: - result = await git("config", "--get", "mergify-cli.github-server") - except CommandError: - result = "" - - url = parse.urlparse(result or "https://api.github.com/") - url = url._replace(scheme="https") - - if url.hostname == "api.github.com": - url = url._replace(path="") - else: - url = url._replace(path="/api/v3") - return url.geturl() - - -async def get_default_branch_prefix(author: str) -> str: - try: - result = await git("config", "--get", "mergify-cli.stack-branch-prefix") - except CommandError: - result = "" - - return result or f"stack/{author}" - - -async def get_default_keep_pr_title_body() -> bool: - try: - result = await git("config", "--get", "mergify-cli.stack-keep-pr-title-body") - except CommandError: - return False - - return result == "true" - - -async def get_default_token() -> str: - token = os.environ.get("GITHUB_TOKEN", "") - if not token: - try: - token = await _run_command("gh", "auth", "token") - except CommandError: - console.print( - "error: please make sure that gh client is installed and you are authenticated, or set the " - "'GITHUB_TOKEN' environment variable", - ) - if DEBUG: - console.print(f"[purple]DEBUG: token: {token}[/]") - return token - - -async def _stack_push(args: argparse.Namespace) -> None: - if args.setup: - # backward compat - await stack_setup(args) - return - - await stack_push( - args.github_server, - args.token, - args.skip_rebase, - args.next_only, - args.branch_prefix, - args.dry_run, - args.trunk, - args.draft, - args.keep_pull_request_title_and_body, - args.only_update_existing_pulls, - args.author, - ) - - -@dataclasses.dataclass -class ChangeNode: - pull: github_types.PullRequest - up: ChangeNode | None = None - - -async def _stack_checkout(args: argparse.Namespace) -> None: - user, repo = args.repository.split("/") - - await stack_checkout( - args.github_server, - args.token, - user, - repo, - args.branch_prefix, - args.branch, - args.author, - args.trunk, - args.dry_run, - ) - - -async def stack_checkout( # noqa: PLR0913, PLR0917 - github_server: str, - token: str, - user: str, - repo: str, - branch_prefix: str | None, - branch: str, - author: str, - trunk: tuple[str, str], - dry_run: bool, -) -> None: - if branch_prefix is None: - branch_prefix = await get_default_branch_prefix(author) - - stack_branch = f"{branch_prefix}/{branch}" if branch_prefix else branch - - async with get_github_http_client(github_server, token) as client: - with console.status("Retrieving latest pushed stacks"): - remote_changes = await get_remote_changes( - client, - user, - repo, - stack_branch, - author, - ) - - root_node: ChangeNode | None = None - - nodes = { - pull["base"]["ref"]: ChangeNode(pull) - for pull in remote_changes.values() - if pull["state"] == "open" - } - - # Linking nodes and finding the base - for node in nodes.values(): - node.up = nodes.get(node.pull["head"]["ref"]) - - if not node.pull["base"]["ref"].startswith(stack_branch): - if root_node is not None: - console.print( - "Unexpected stack layout, two root commits found", - style="red", - ) - sys.exit(1) - root_node = node - - if root_node is None: - console.print("No stacked pull requests found") - sys.exit(0) - - console.log("Stacked pull requests:") - node = root_node - while True: - pull = node.pull - console.log( - f"* [b][white]#{pull['number']}[/] {pull['title']}[/] {pull['html_url']}", - ) - console.log(f" [grey42]{pull['base']['ref']} -> {pull['head']['ref']}[/]") - - if node.up is None: - break - node = node.up - - if dry_run: - return - - remote = trunk[0] - upstream = f"{remote}/{root_node.pull['base']['ref']}" - head_ref = f"{remote}/{node.pull['head']['ref']}" - await git("fetch", remote, node.pull["head"]["ref"]) - await git("checkout", "-b", branch, head_ref) - await git("branch", f"--set-upstream-to={upstream}") - - -async def _stack_github_action_auto_rebase(args: argparse.Namespace) -> None: - for env in ("GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH", "GITHUB_REPOSITORY"): - if env not in os.environ: - console.log("This action only works in a GitHub Action", style="red") - sys.exit(1) - - event_name = os.environ["GITHUB_EVENT_NAME"] - event_path = os.environ["GITHUB_EVENT_PATH"] - user, repo = os.environ["GITHUB_REPOSITORY"].split("/") - - async with aiofiles.open(event_path) as f: - event = json.loads(await f.read()) - - if event_name != "issue_comment" or not event["issue"]["pull_request"]: - console.log( - "This action only works with `issue_comment` event for pull request", - style="red", - ) - sys.exit(1) - - async with get_github_http_client(args.github_server, args.token) as client: - await client.post( - f"/repos/{user}/{repo}/issues/comments/{event['comment']['id']}/reactions", - json={"content": "+1"}, - ) - resp = await client.get(event["issue"]["pull_request"]["url"]) - pull = resp.json() - - author = pull["user"]["login"] - base = pull["base"]["ref"] - head = pull["head"]["ref"] - - head_changeid = head.split("/")[-1] - if not head_changeid.startswith("I") or len(head_changeid) != 41: - console.log("This pull request is not part of a stack", style="red") - sys.exit(1) - - base_changeid = base.split("/")[-1] - if base_changeid.startswith("I") and len(base_changeid) == 41: - console.log("This pull request is not the bottom of the stack", style="red") - sys.exit(1) - - stack_branch = head.removesuffix(f"/{head_changeid}") - - await git("config", "--global", "user.name", f"{author}") - await git("config", "--global", "user.email", f"{author}@users.noreply.github.com") - await git("branch", "--set-upstream-to", f"origin/{base}") - - await stack_checkout( - args.github_server, - args.token, - user=user, - repo=repo, - branch_prefix="", - branch=stack_branch, - author=author, - trunk=("origin", base), - dry_run=False, - ) - await stack_push( - args.github_server, - args.token, - skip_rebase=False, - next_only=False, - branch_prefix="", - dry_run=False, - trunk=("origin", base), - create_as_draft=False, - keep_pull_request_title_and_body=True, - only_update_existing_pulls=False, - author=author, - ) - - async with get_github_http_client(args.github_server, args.token) as client: - body_quote = "> " + "\n> ".join(event["comment"]["body"].split("\n")) - await client.post( - f"/repos/{user}/{repo}/issues/{pull['number']}/comments", - json={"body": f"{body_quote}\n\nThe stack has been rebased"}, - ) - - -def register_stack_setup_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "setup", - description="Configure the git hooks", - help="Initial installation of the required git commit-msg hook", - ) - parser.set_defaults(func=stack_setup) - - -def register_stack_edit_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "edit", - description="Edit the stack history", - help="Edit the stack history", - ) - parser.set_defaults(func=stack_edit) - - -def register_stack_github_action_autorebase( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "github-action-auto-rebase", - description="Autorebase a pull requests stack", - help="Checkout a pull requests stack", - ) - parser.set_defaults(func=_stack_github_action_auto_rebase) - - -async def register_stack_checkout_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "checkout", - description="Checkout a pull requests stack", - help="Checkout a pull requests stack", - ) - parser.set_defaults(func=_stack_checkout) - parser.add_argument( - "--author", - help="Set the author of the stack (default: the author of the token)", - ) - parser.add_argument( - "--repository", - "--repo", - help="Set the repository where the stack is located (eg: owner/repo)", - ) - parser.add_argument( - "--branch", - help="Branch used to create stacked PR.", - ) - parser.add_argument( - "--branch-prefix", - default=None, - help="Branch prefix used to create stacked PR. " - "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", - ) - parser.add_argument( - "--dry-run", - "-n", - action="store_true", - help="Only show what is going to be done", - ) - parser.add_argument( - "--trunk", - "-t", - type=trunk_type, - default=await get_trunk(), - help="Change the target branch of the stack.", - ) - - -async def register_stack_push_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "push", - description="Push/sync the pull requests stack", - help="Push/sync the pull requests stack", - ) - parser.set_defaults(func=_stack_push) - - # Backward compat - parser.add_argument( - "--setup", - action="store_true", - help="Initial installation of the required git commit-msg hook", - ) - - parser.add_argument( - "--dry-run", - "-n", - action="store_true", - help="Only show what is going to be done", - ) - parser.add_argument( - "--next-only", - "-x", - action="store_true", - help="Only rebase and update the next pull request of the stack", - ) - parser.add_argument( - "--skip-rebase", - "-R", - action="store_true", - help="Skip stack rebase", - ) - parser.add_argument( - "--draft", - "-d", - action="store_true", - help="Create stacked pull request as draft", - ) - parser.add_argument( - "--keep-pull-request-title-and-body", - "-k", - action="store_true", - default=await get_default_keep_pr_title_body(), - help="Don't update the title and body of already opened pull requests. " - "Default fetched from git config if added with `git config --add mergify-cli.stack-keep-pr-title-body true`", - ) - parser.add_argument( - "--author", - help="Set the author of the stack (default: the author of the token)", - ) - - parser.add_argument( - "--trunk", - "-t", - type=trunk_type, - default=await get_trunk(), - help="Change the target branch of the stack.", - ) - parser.add_argument( - "--branch-prefix", - default=None, - help="Branch prefix used to create stacked PR. " - "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", - ) - parser.add_argument( - "--only-update-existing-pulls", - "-u", - action="store_true", - help="Only update existing pull requests, do not create new ones", - ) - - -async def parse_args(args: typing.MutableSequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument( - "--version", - "-V", - action="version", - version=f"%(prog)s {VERSION}", - help="display version", - ) - parser.add_argument("--debug", action="store_true", help="debug mode") - parser.add_argument( - "--token", - default=await get_default_token(), - type=GitHubToken, - help="GitHub personal access token", - ) - parser.add_argument("--dry-run", "-n", action="store_true") - parser.add_argument( - "--github-server", - action="store_true", - default=await get_default_github_server(), - ) - - sub_parsers = parser.add_subparsers(dest="action") - - stack_parser = sub_parsers.add_parser( - "stack", - description="Stacked Pull Requests CLI", - help="Create a pull requests stack", - ) - stack_sub_parsers = stack_parser.add_subparsers(dest="stack_action") - await register_stack_push_parser(stack_sub_parsers) - await register_stack_checkout_parser(stack_sub_parsers) - register_stack_edit_parser(stack_sub_parsers) - register_stack_setup_parser(stack_sub_parsers) - register_stack_github_action_autorebase(stack_sub_parsers) - - known_args, _ = parser.parse_known_args(args) - - # Default - if known_args.action is None: - args.insert(0, "stack") - - known_args, _ = parser.parse_known_args(args) - - if known_args.action == "stack" and known_args.stack_action is None: - args.insert(1, "push") - - return parser.parse_args(args) - - -async def main() -> None: - args = await parse_args(sys.argv[1:]) - - if args.debug: - global DEBUG # noqa: PLW0603 - DEBUG = True - - await args.func(args) - - -def cli() -> None: - asyncio.run(main()) +VERSION = importlib.metadata.version("mergify-cli") diff --git a/mergify_cli/cli.py b/mergify_cli/cli.py new file mode 100644 index 0000000..4fc4207 --- /dev/null +++ b/mergify_cli/cli.py @@ -0,0 +1,339 @@ +# +# Copyright © 2021-2024 Mergify SAS +# +# Licensed 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 argparse +import asyncio +import os +import sys +import typing +from urllib import parse + +from mergify_cli import VERSION +from mergify_cli import console +from mergify_cli import utils +from mergify_cli.stack import checkout +from mergify_cli.stack import edit +from mergify_cli.stack import github_action_auto_rebase +from mergify_cli.stack import push +from mergify_cli.stack import setup + + +def trunk_type(trunk: str) -> tuple[str, str]: + result = trunk.split("/", maxsplit=1) + if len(result) != 2: + msg = "Trunk is invalid. It must be origin/branch-name [/]" + raise argparse.ArgumentTypeError(msg) + return result[0], result[1] + + +def GitHubToken(v: str) -> str: # noqa: N802 + if not v: + raise ValueError + return v + + +async def get_default_github_server() -> str: + try: + result = await utils.git("config", "--get", "mergify-cli.github-server") + except utils.CommandError: + result = "" + + url = parse.urlparse(result or "https://api.github.com/") + url = url._replace(scheme="https") + + if url.hostname == "api.github.com": + url = url._replace(path="") + else: + url = url._replace(path="/api/v3") + return url.geturl() + + +async def get_default_keep_pr_title_body() -> bool: + try: + result = await utils.git( + "config", + "--get", + "mergify-cli.stack-keep-pr-title-body", + ) + except utils.CommandError: + return False + + return result == "true" + + +async def get_default_token() -> str: + token = os.environ.get("GITHUB_TOKEN", "") + if not token: + try: + token = await utils.run_command("gh", "auth", "token") + except utils.CommandError: + console.print( + "error: please make sure that gh client is installed and you are authenticated, or set the " + "'GITHUB_TOKEN' environment variable", + ) + if utils.is_debug(): + console.print(f"[purple]DEBUG: token: {token}[/]") + return token + + +async def _stack_push(args: argparse.Namespace) -> None: + if args.setup: + # backward compat + await setup.stack_setup(args) + return + + await push.stack_push( + args.github_server, + args.token, + args.skip_rebase, + args.next_only, + args.branch_prefix, + args.dry_run, + args.trunk, + args.draft, + args.keep_pull_request_title_and_body, + args.only_update_existing_pulls, + args.author, + ) + + +async def _stack_checkout(args: argparse.Namespace) -> None: + user, repo = args.repository.split("/") + + await checkout.stack_checkout( + args.github_server, + args.token, + user, + repo, + args.branch_prefix, + args.branch, + args.author, + args.trunk, + args.dry_run, + ) + + +def register_stack_setup_parser( + sub_parsers: argparse._SubParsersAction[typing.Any], +) -> None: + parser = sub_parsers.add_parser( + "setup", + description="Configure the git hooks", + help="Initial installation of the required git commit-msg hook", + ) + parser.set_defaults(func=setup.stack_setup) + + +def register_stack_edit_parser( + sub_parsers: argparse._SubParsersAction[typing.Any], +) -> None: + parser = sub_parsers.add_parser( + "edit", + description="Edit the stack history", + help="Edit the stack history", + ) + parser.set_defaults(func=edit.stack_edit) + + +def register_stack_github_action_autorebase( + sub_parsers: argparse._SubParsersAction[typing.Any], +) -> None: + parser = sub_parsers.add_parser( + "github-action-auto-rebase", + description="Autorebase a pull requests stack", + help="Checkout a pull requests stack", + ) + parser.set_defaults(func=github_action_auto_rebase.stack_github_action_auto_rebase) + + +async def register_stack_checkout_parser( + sub_parsers: argparse._SubParsersAction[typing.Any], +) -> None: + parser = sub_parsers.add_parser( + "checkout", + description="Checkout a pull requests stack", + help="Checkout a pull requests stack", + ) + parser.set_defaults(func=_stack_checkout) + parser.add_argument( + "--author", + help="Set the author of the stack (default: the author of the token)", + ) + parser.add_argument( + "--repository", + "--repo", + help="Set the repository where the stack is located (eg: owner/repo)", + ) + parser.add_argument( + "--branch", + help="Branch used to create stacked PR.", + ) + parser.add_argument( + "--branch-prefix", + default=None, + help="Branch prefix used to create stacked PR. " + "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", + ) + parser.add_argument( + "--dry-run", + "-n", + action="store_true", + help="Only show what is going to be done", + ) + parser.add_argument( + "--trunk", + "-t", + type=trunk_type, + default=await utils.get_trunk(), + help="Change the target branch of the stack.", + ) + + +async def register_stack_push_parser( + sub_parsers: argparse._SubParsersAction[typing.Any], +) -> None: + parser = sub_parsers.add_parser( + "push", + description="Push/sync the pull requests stack", + help="Push/sync the pull requests stack", + ) + parser.set_defaults(func=_stack_push) + + # Backward compat + parser.add_argument( + "--setup", + action="store_true", + help="Initial installation of the required git commit-msg hook", + ) + + parser.add_argument( + "--dry-run", + "-n", + action="store_true", + help="Only show what is going to be done", + ) + parser.add_argument( + "--next-only", + "-x", + action="store_true", + help="Only rebase and update the next pull request of the stack", + ) + parser.add_argument( + "--skip-rebase", + "-R", + action="store_true", + help="Skip stack rebase", + ) + parser.add_argument( + "--draft", + "-d", + action="store_true", + help="Create stacked pull request as draft", + ) + parser.add_argument( + "--keep-pull-request-title-and-body", + "-k", + action="store_true", + default=await get_default_keep_pr_title_body(), + help="Don't update the title and body of already opened pull requests. " + "Default fetched from git config if added with `git config --add mergify-cli.stack-keep-pr-title-body true`", + ) + parser.add_argument( + "--author", + help="Set the author of the stack (default: the author of the token)", + ) + + parser.add_argument( + "--trunk", + "-t", + type=trunk_type, + default=await utils.get_trunk(), + help="Change the target branch of the stack.", + ) + parser.add_argument( + "--branch-prefix", + default=None, + help="Branch prefix used to create stacked PR. " + "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", + ) + parser.add_argument( + "--only-update-existing-pulls", + "-u", + action="store_true", + help="Only update existing pull requests, do not create new ones", + ) + + +async def parse_args(args: typing.MutableSequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--version", + "-V", + action="version", + version=f"%(prog)s {VERSION}", + help="display version", + ) + parser.add_argument("--debug", action="store_true", help="debug mode") + parser.add_argument( + "--token", + default=await get_default_token(), + type=GitHubToken, + help="GitHub personal access token", + ) + parser.add_argument("--dry-run", "-n", action="store_true") + parser.add_argument( + "--github-server", + action="store_true", + default=await get_default_github_server(), + ) + + sub_parsers = parser.add_subparsers(dest="action") + + stack_parser = sub_parsers.add_parser( + "stack", + description="Stacked Pull Requests CLI", + help="Create a pull requests stack", + ) + stack_sub_parsers = stack_parser.add_subparsers(dest="stack_action") + await register_stack_push_parser(stack_sub_parsers) + await register_stack_checkout_parser(stack_sub_parsers) + register_stack_edit_parser(stack_sub_parsers) + register_stack_setup_parser(stack_sub_parsers) + register_stack_github_action_autorebase(stack_sub_parsers) + + known_args, _ = parser.parse_known_args(args) + + # Default + if known_args.action is None: + args.insert(0, "stack") + + known_args, _ = parser.parse_known_args(args) + + if known_args.action == "stack" and known_args.stack_action is None: + args.insert(1, "push") + + return parser.parse_args(args) + + +async def async_main() -> None: + args = await parse_args(sys.argv[1:]) + utils.set_debug(args.debug) + await args.func(args) + + +def main() -> None: + asyncio.run(async_main()) diff --git a/mergify_cli/stack/__init__.py b/mergify_cli/stack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mergify_cli/stack/changes.py b/mergify_cli/stack/changes.py new file mode 100644 index 0000000..5033e0d --- /dev/null +++ b/mergify_cli/stack/changes.py @@ -0,0 +1,299 @@ +# +# Copyright © 2021-2024 Mergify SAS +# +# Licensed 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. + + +import asyncio +import dataclasses +import re +import sys +import typing + +import httpx + +from mergify_cli import console +from mergify_cli import github_types +from mergify_cli import utils + + +CHANGEID_RE = re.compile(r"Change-Id: (I[0-9a-z]{40})") + +ChangeId = typing.NewType("ChangeId", str) +RemoteChanges = typing.NewType( + "RemoteChanges", + dict[ChangeId, github_types.PullRequest], +) + +ActionT = typing.Literal[ + "skip-merged", + "skip-next-only", + "skip-create", + "skip-up-to-date", + "create", + "update", +] + + +class PullRequestNotExistError(Exception): + pass + + +@dataclasses.dataclass +class Change: + id: ChangeId + pull: github_types.PullRequest | None + + @property + def pull_head_sha(self) -> str: + if self.pull is None: + raise PullRequestNotExistError + return self.pull["head"]["sha"] + + @property + def pull_short_head_sha(self) -> str: + return self.pull_head_sha[:7] + + +async def get_remote_changes( + client: httpx.AsyncClient, + user: str, + repo: str, + stack_prefix: str, + author: str, +) -> RemoteChanges: + r_repo = await client.get(f"/repos/{user}/{repo}") + repository = r_repo.json() + + r = await client.get( + "/search/issues", + params={ + "repository_id": repository["id"], + "q": f"author:{author} is:pull-request head:{stack_prefix}", + "per_page": 100, + "sort": "updated", + }, + ) + + responses = await asyncio.gather( + *(client.get(item["pull_request"]["url"]) for item in r.json()["items"]), + ) + pulls = [typing.cast(github_types.PullRequest, r.json()) for r in responses] + + remote_changes = RemoteChanges({}) + for pull in pulls: + # Drop closed but not merged PR + if pull["state"] == "closed" and pull["merged_at"] is None: + continue + + changeid = ChangeId(pull["head"]["ref"].split("/")[-1]) + + if changeid in remote_changes: + other_pull = remote_changes[changeid] + if other_pull["state"] == "closed" and pull["state"] == "closed": + # Keep the more recent + pass + elif other_pull["state"] == "closed" and pull["state"] == "open": + remote_changes[changeid] = pull + elif other_pull["state"] == "opened": + msg = f"More than 1 pull found with this head: {pull['head']['ref']}" + raise RuntimeError(msg) + + else: + remote_changes[changeid] = pull + + return remote_changes + + +@dataclasses.dataclass +class LocalChange(Change): + commit_sha: str + title: str + message: str + base_branch: str + dest_branch: str + action: ActionT + + @property + def commit_short_sha(self) -> str: + return self.commit_sha[:7] + + def get_log_from_local_change( + self, + dry_run: bool, + create_as_draft: bool, + ) -> str: + url = f"<{self.dest_branch}>" if self.pull is None else self.pull["html_url"] + + flags: str = "" + if self.pull and self.pull["draft"]: + flags += " [yellow](draft)[/]" + + if self.action == "create": + color = "yellow" if dry_run else "blue" + action = "to create" if dry_run else "created" + commit_info = self.commit_short_sha + if create_as_draft: + flags += " [yellow](draft)[/]" + + elif self.action == "update": + color = "yellow" if dry_run else "blue" + action = "to update" if dry_run else "updated" + commit_info = f"{self.pull_short_head_sha} -> {self.commit_short_sha}" + + elif self.action == "skip-create": + color = "grey" + action = "skip, --only-update-existing-pulls" + commit_info = self.commit_short_sha + + elif self.action == "skip-merged": + color = "purple" + action = "merged" + flags += " [purple](merged)[/]" + commit_info = ( + f"{self.pull['merge_commit_sha'][7:]}" + if self.pull + and self.pull["merged_at"] + and self.pull["merge_commit_sha"] + else self.commit_short_sha + ) + + elif self.action == "skip-next-only": + color = "grey" + action = "skip, --next-only" + commit_info = self.commit_short_sha + + elif self.action == "skip-up-to-date": + color = "grey" + action = "up-to-date" + commit_info = self.commit_short_sha + + else: + # NOTE: we don't want to miss any action + msg = f"Unhandled action: {self.action}" # type: ignore[unreachable] + raise RuntimeError(msg) + + return f"* [{color}]\\[{action}][/] '[red]{commit_info}[/] - [b]{self.title}[/]{flags} {url}" + + +@dataclasses.dataclass +class OrphanChange(Change): + def get_log_from_orphan_change(self, dry_run: bool) -> str: + action = "to delete" if dry_run else "deleted" + title = self.pull["title"] if self.pull else "" + url = self.pull["html_url"] if self.pull else "" + sha = self.pull["head"]["sha"][7:] if self.pull else "" + return f"* [red]\\[{action}][/] '[red]{sha}[/] - [b]{title}[/] {url}" + + +@dataclasses.dataclass +class Changes: + stack_prefix: str + locals: list[LocalChange] = dataclasses.field(default_factory=list) + orphans: list[OrphanChange] = dataclasses.field(default_factory=list) + + +def display_plan( + changes: Changes, + create_as_draft: bool, +) -> None: + for change in changes.locals: + console.log( + change.get_log_from_local_change( + dry_run=True, + create_as_draft=create_as_draft, + ), + ) + + for orphan in changes.orphans: + console.log(orphan.get_log_from_orphan_change(dry_run=True)) + + +async def get_changes( # noqa: PLR0913,PLR0917 + base_commit_sha: str, + stack_prefix: str, + base_branch: str, + dest_branch: str, + remote_changes: RemoteChanges, + only_update_existing_pulls: bool, + next_only: bool, +) -> Changes: + commits = ( + commit + for commit in reversed( + ( + await utils.git( + "log", + "--format=%H", + f"{base_commit_sha}..{dest_branch}", + ) + ).split( + "\n", + ), + ) + if commit + ) + changes = Changes(stack_prefix) + remaining_remote_changes = remote_changes.copy() + + for idx, commit in enumerate(commits): + message = await utils.git("log", "-1", "--format=%b", commit) + title = await utils.git("log", "-1", "--format=%s", commit) + + changeids = CHANGEID_RE.findall(message) + if not changeids: + console.print( + f"`Change-Id:` line is missing on commit {commit}", + style="red", + ) + console.print( + "Did you run `mergify stack --setup` for this repository?", + ) + # TODO(sileht): we should raise an Exception and exit in main program + sys.exit(1) + + changeid = ChangeId(changeids[-1]) + pull = remaining_remote_changes.pop(changeid, None) + + action: ActionT + if next_only and idx > 0: + action = "skip-next-only" + elif pull is None: + if only_update_existing_pulls: + action = "skip-create" + action = "create" + elif pull["merged_at"]: + action = "skip-merged" + elif pull["head"]["sha"] == commit: + action = "skip-up-to-date" + else: + action = "update" + + changes.locals.append( + LocalChange( + changeid, + pull, + commit, + title, + message, + changes.locals[-1].dest_branch if changes.locals else base_branch, + f"{stack_prefix}/{changeid}", + action, + ), + ) + + for changeid, pull in remaining_remote_changes.items(): + if pull["state"] == "open": + changes.orphans.append(OrphanChange(changeid, pull)) + + return changes diff --git a/mergify_cli/stack/checkout.py b/mergify_cli/stack/checkout.py new file mode 100644 index 0000000..09cd167 --- /dev/null +++ b/mergify_cli/stack/checkout.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import dataclasses +import sys + +from mergify_cli import console +from mergify_cli import github_types +from mergify_cli import utils +from mergify_cli.stack import changes + + +@dataclasses.dataclass +class ChangeNode: + pull: github_types.PullRequest + up: ChangeNode | None = None + + +async def stack_checkout( # noqa: PLR0913, PLR0917 + github_server: str, + token: str, + user: str, + repo: str, + branch_prefix: str | None, + branch: str, + author: str, + trunk: tuple[str, str], + dry_run: bool, +) -> None: + if branch_prefix is None: + branch_prefix = await utils.get_default_branch_prefix(author) + + stack_branch = f"{branch_prefix}/{branch}" if branch_prefix else branch + + async with utils.get_github_http_client(github_server, token) as client: + with console.status("Retrieving latest pushed stacks"): + remote_changes = await changes.get_remote_changes( + client, + user, + repo, + stack_branch, + author, + ) + + root_node: ChangeNode | None = None + + nodes = { + pull["base"]["ref"]: ChangeNode(pull) + for pull in remote_changes.values() + if pull["state"] == "open" + } + + # Linking nodes and finding the base + for node in nodes.values(): + node.up = nodes.get(node.pull["head"]["ref"]) + + if not node.pull["base"]["ref"].startswith(stack_branch): + if root_node is not None: + console.print( + "Unexpected stack layout, two root commits found", + style="red", + ) + sys.exit(1) + root_node = node + + if root_node is None: + console.print("No stacked pull requests found") + sys.exit(0) + + console.log("Stacked pull requests:") + node = root_node + while True: + pull = node.pull + console.log( + f"* [b][white]#{pull['number']}[/] {pull['title']}[/] {pull['html_url']}", + ) + console.log(f" [grey42]{pull['base']['ref']} -> {pull['head']['ref']}[/]") + + if node.up is None: + break + node = node.up + + if dry_run: + return + + remote = trunk[0] + upstream = f"{remote}/{root_node.pull['base']['ref']}" + head_ref = f"{remote}/{node.pull['head']['ref']}" + await utils.git("fetch", remote, node.pull["head"]["ref"]) + await utils.git("checkout", "-b", branch, head_ref) + await utils.git("branch", f"--set-upstream-to={upstream}") diff --git a/mergify_cli/stack/edit.py b/mergify_cli/stack/edit.py new file mode 100644 index 0000000..8f929ce --- /dev/null +++ b/mergify_cli/stack/edit.py @@ -0,0 +1,11 @@ +import argparse +import os + +from mergify_cli import utils + + +async def stack_edit(_: argparse.Namespace) -> None: + os.chdir(await utils.git("rev-parse", "--show-toplevel")) + trunk = await utils.get_trunk() + base = await utils.git("merge-base", trunk, "HEAD") + os.execvp("git", ("git", "rebase", "-i", f"{base}^")) # noqa: S606 diff --git a/mergify_cli/stack/github_action_auto_rebase.py b/mergify_cli/stack/github_action_auto_rebase.py new file mode 100644 index 0000000..e4e2d9d --- /dev/null +++ b/mergify_cli/stack/github_action_auto_rebase.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +import os +import sys +import typing + +import aiofiles + +from mergify_cli import console +from mergify_cli import utils +from mergify_cli.stack import checkout +from mergify_cli.stack import push + + +if typing.TYPE_CHECKING: + import argparse + + +async def stack_github_action_auto_rebase(args: argparse.Namespace) -> None: + for env in ("GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH", "GITHUB_REPOSITORY"): + if env not in os.environ: + console.log("This action only works in a GitHub Action", style="red") + sys.exit(1) + + event_name = os.environ["GITHUB_EVENT_NAME"] + event_path = os.environ["GITHUB_EVENT_PATH"] + user, repo = os.environ["GITHUB_REPOSITORY"].split("/") + + async with aiofiles.open(event_path) as f: + event = json.loads(await f.read()) + + if event_name != "issue_comment" or not event["issue"]["pull_request"]: + console.log( + "This action only works with `issue_comment` event for pull request", + style="red", + ) + sys.exit(1) + + async with utils.get_github_http_client(args.github_server, args.token) as client: + await client.post( + f"/repos/{user}/{repo}/issues/comments/{event['comment']['id']}/reactions", + json={"content": "+1"}, + ) + resp = await client.get(event["issue"]["pull_request"]["url"]) + pull = resp.json() + + author = pull["user"]["login"] + base = pull["base"]["ref"] + head = pull["head"]["ref"] + + head_changeid = head.split("/")[-1] + if not head_changeid.startswith("I") or len(head_changeid) != 41: + console.log("This pull request is not part of a stack", style="red") + sys.exit(1) + + base_changeid = base.split("/")[-1] + if base_changeid.startswith("I") and len(base_changeid) == 41: + console.log("This pull request is not the bottom of the stack", style="red") + sys.exit(1) + + stack_branch = head.removesuffix(f"/{head_changeid}") + + await utils.git("config", "--global", "user.name", f"{author}") + await utils.git( + "config", + "--global", + "user.email", + f"{author}@users.noreply.github.com", + ) + await utils.git("branch", "--set-upstream-to", f"origin/{base}") + + await checkout.stack_checkout( + args.github_server, + args.token, + user=user, + repo=repo, + branch_prefix="", + branch=stack_branch, + author=author, + trunk=("origin", base), + dry_run=False, + ) + await push.stack_push( + args.github_server, + args.token, + skip_rebase=False, + next_only=False, + branch_prefix="", + dry_run=False, + trunk=("origin", base), + create_as_draft=False, + keep_pull_request_title_and_body=True, + only_update_existing_pulls=False, + author=author, + ) + + async with utils.get_github_http_client(args.github_server, args.token) as client: + body_quote = "> " + "\n> ".join(event["comment"]["body"].split("\n")) + await client.post( + f"/repos/{user}/{repo}/issues/{pull['number']}/comments", + json={"body": f"{body_quote}\n\nThe stack has been rebased"}, + ) diff --git a/mergify_cli/stack/push.py b/mergify_cli/stack/push.py new file mode 100644 index 0000000..d3c04a5 --- /dev/null +++ b/mergify_cli/stack/push.py @@ -0,0 +1,359 @@ +# +# Copyright © 2021-2024 Mergify SAS +# +# Licensed 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 asyncio +import dataclasses +import os +import re +import sys +import typing + +from mergify_cli import console +from mergify_cli import github_types +from mergify_cli import utils +from mergify_cli.stack import changes + + +if typing.TYPE_CHECKING: + import httpx + +DEPENDS_ON_RE = re.compile(r"Depends-On: (#[0-9]*)") +TMP_STACK_BRANCH = "mergify-cli-tmp" + + +@dataclasses.dataclass +class LocalBranchInvalidError(Exception): + message: str + + +def check_local_branch(branch_name: str, branch_prefix: str) -> None: + if branch_name.startswith(branch_prefix) and re.search( + r"I[0-9a-z]{40}$", + branch_name, + ): + msg = "Local branch is a branch generated by Mergify CLI" + raise LocalBranchInvalidError(msg) + + +def format_pull_description( + message: str, + depends_on: github_types.PullRequest | None, +) -> str: + depends_on_header = "" + if depends_on is not None: + depends_on_header = f"\n\nDepends-On: #{depends_on['number']}" + + message = changes.CHANGEID_RE.sub("", message).rstrip("\n") + message = DEPENDS_ON_RE.sub("", message).rstrip("\n") + + return message + depends_on_header + + +# TODO(charly): fix code to conform to linter (number of arguments, local +# variables, statements, positional arguments, branches) +async def stack_push( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 + github_server: str, + token: str, + skip_rebase: bool, + next_only: bool, + branch_prefix: str | None, + dry_run: bool, + trunk: tuple[str, str], + create_as_draft: bool = False, + keep_pull_request_title_and_body: bool = False, + only_update_existing_pulls: bool = False, + author: str | None = None, +) -> None: + os.chdir(await utils.git("rev-parse", "--show-toplevel")) + dest_branch = await utils.git_get_branch_name() + + if author is None: + async with utils.get_github_http_client(github_server, token) as client: + r_author = await client.get("/user") + author = r_author.json()["login"] + + if branch_prefix is None: + branch_prefix = await utils.get_default_branch_prefix(author) + + try: + check_local_branch(branch_name=dest_branch, branch_prefix=branch_prefix) + except LocalBranchInvalidError as e: + console.log(f"[red] {e.message} [/]") + console.log( + "You should run `mergify stack` on the branch you created in the first place", + ) + sys.exit(1) + + remote, base_branch = trunk + + user, repo = utils.get_slug( + await utils.git("config", "--get", f"remote.{remote}.url"), + ) + + if base_branch == dest_branch: + console.log("[red] base branch and destination branch are the same [/]") + sys.exit(1) + + stack_prefix = f"{branch_prefix}/{dest_branch}" if branch_prefix else dest_branch + + if not dry_run: + if skip_rebase: + console.log(f"branch `{dest_branch}` rebase skipped (--skip-rebase)") + else: + with console.status( + f"Rebasing branch `{dest_branch}` on `{remote}/{base_branch}`...", + ): + await utils.git("pull", "--rebase", remote, base_branch) + console.log(f"branch `{dest_branch}` rebased on `{remote}/{base_branch}`") + + base_commit_sha = await utils.git( + "merge-base", + "--fork-point", + f"{remote}/{base_branch}", + ) + if not base_commit_sha: + console.log( + f"Common commit between `{remote}/{base_branch}` and `{dest_branch}` branches not found", + style="red", + ) + sys.exit(1) + + async with utils.get_github_http_client(github_server, token) as client: + with console.status("Retrieving latest pushed stacks"): + remote_changes = await changes.get_remote_changes( + client, + user, + repo, + stack_prefix, + author, + ) + + with console.status("Preparing stacked branches..."): + console.log("Stacked pull request plan:", style="green") + planned_changes = await changes.get_changes( + base_commit_sha, + stack_prefix, + base_branch, + dest_branch, + remote_changes, + only_update_existing_pulls, + next_only, + ) + + changes.display_plan( + planned_changes, + create_as_draft, + ) + + if dry_run: + console.log("[orange]Finished (dry-run mode) :tada:[/]") + sys.exit(0) + + console.log("Updating and/or creating stacked pull requests:", style="green") + + pulls_to_comment: list[github_types.PullRequest] = [] + for change in planned_changes.locals: + depends_on = pulls_to_comment[-1] if pulls_to_comment else None + + if change.action in {"create", "update"}: + pull = await create_or_update_stack( + client, + user, + repo, + remote, + change, + depends_on, + create_as_draft, + keep_pull_request_title_and_body, + ) + change.pull = pull + + if change.pull: + pulls_to_comment.append(change.pull) + + console.log( + change.get_log_from_local_change( + dry_run=False, + create_as_draft=create_as_draft, + ), + ) + + with console.status("Updating comments..."): + await create_or_update_comments(client, user, repo, pulls_to_comment) + + console.log("[green]Comments updated") + + with console.status("Deleting unused branches..."): + if planned_changes.orphans: + await asyncio.wait( + asyncio.create_task( + delete_stack(client, user, repo, stack_prefix, change), + ) + for change in planned_changes.orphans + ) + + console.log("[green]Finished :tada:[/]") + + +@dataclasses.dataclass +class StackComment: + pulls: list[github_types.PullRequest] + + STACK_COMMENT_FIRST_LINE = "This pull request is part of a stack:\n" + + def body(self, current_pull: github_types.PullRequest) -> str: + body = self.STACK_COMMENT_FIRST_LINE + + for pull in self.pulls: + body += f"1. {pull['title']} ([#{pull['number']}]({pull['html_url']}))" + if pull == current_pull: + body += " 👈" + body += "\n" + + return body + + @staticmethod + def is_stack_comment(comment: github_types.Comment) -> bool: + return comment["body"].startswith(StackComment.STACK_COMMENT_FIRST_LINE) + + +async def create_or_update_comments( + client: httpx.AsyncClient, + user: str, + repo: str, + pulls: list[github_types.PullRequest], +) -> None: + stack_comment = StackComment(pulls) + + for pull in pulls: + if pull["merged_at"]: + continue + + new_body = stack_comment.body(pull) + + r = await client.get(f"/repos/{user}/{repo}/issues/{pull['number']}/comments") + comments = typing.cast(list[github_types.Comment], r.json()) + for comment in comments: + if StackComment.is_stack_comment(comment): + if comment["body"] != new_body: + await client.patch(comment["url"], json={"body": new_body}) + break + else: + # NOTE(charly): dont't create a stack comment if there is only one + # pull, it's not a stack + if len(pulls) == 1: + continue + + await client.post( + f"/repos/{user}/{repo}/issues/{pull['number']}/comments", + json={"body": new_body}, + ) + + +async def delete_stack( + client: httpx.AsyncClient, + user: str, + repo: str, + stack_prefix: str, + change: changes.OrphanChange, +) -> None: + await client.delete( + f"/repos/{user}/{repo}/git/refs/heads/{stack_prefix}/{change.id}", + ) + console.log(change.get_log_from_orphan_change(dry_run=False)) + + +async def create_or_update_stack( # noqa: PLR0913,PLR0917 + client: httpx.AsyncClient, + user: str, + repo: str, + remote: str, + change: changes.LocalChange, + depends_on: github_types.PullRequest | None, + create_as_draft: bool, + keep_pull_request_title_and_body: bool, +) -> github_types.PullRequest: + if change.pull is None: + status_message = f"* creating stacked branch `{change.dest_branch}` ({change.commit_short_sha})" + else: + status_message = f"* updating stacked branch `{change.dest_branch}` ({change.commit_short_sha}) - {change.pull['html_url'] if change.pull else ''})" + + with console.status(status_message): + await utils.git("branch", TMP_STACK_BRANCH, change.commit_sha) + try: + await utils.git( + "push", + "-f", + remote, + TMP_STACK_BRANCH + ":" + change.dest_branch, + ) + finally: + await utils.git("branch", "-D", TMP_STACK_BRANCH) + + if change.action == "update": + if change.pull is None: + msg = "Can't update pull with change.pull unset" + raise RuntimeError(msg) + + with console.status( + f"* updating pull request `{change.title}` (#{change.pull['number']}) ({change.commit_short_sha})", + ): + pull_changes = { + "head": change.dest_branch, + "base": change.base_branch, + } + if keep_pull_request_title_and_body: + pull_changes.update( + { + "body": format_pull_description( + change.pull["body"] or "", + depends_on, + ), + }, + ) + else: + pull_changes.update( + { + "title": change.title, + "body": format_pull_description(change.message, depends_on), + }, + ) + + r = await client.patch( + f"/repos/{user}/{repo}/pulls/{change.pull['number']}", + json=pull_changes, + ) + return change.pull + + elif change.action == "create": + with console.status( + f"* creating stacked pull request `{change.title}` ({change.commit_short_sha})", + ): + r = await client.post( + f"/repos/{user}/{repo}/pulls", + json={ + "title": change.title, + "body": format_pull_description(change.message, depends_on), + "draft": create_as_draft, + "head": change.dest_branch, + "base": change.base_branch, + }, + ) + return typing.cast(github_types.PullRequest, r.json()) + + msg = f"Unhandled action: {change.action}" + raise RuntimeError(msg) diff --git a/mergify_cli/stack/setup.py b/mergify_cli/stack/setup.py new file mode 100644 index 0000000..0b3f3aa --- /dev/null +++ b/mergify_cli/stack/setup.py @@ -0,0 +1,59 @@ +# +# Copyright © 2021-2024 Mergify SAS +# +# Licensed 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 importlib.metadata +import pathlib +import shutil +import sys +import typing + +import aiofiles + +from mergify_cli import console +from mergify_cli import utils + + +if typing.TYPE_CHECKING: + import argparse + + +async def stack_setup(_: argparse.Namespace) -> None: + hooks_dir = pathlib.Path(await utils.git("rev-parse", "--git-path", "hooks")) + installed_hook_file = hooks_dir / "commit-msg" + + new_hook_file = str( + importlib.resources.files(__package__).joinpath("hooks/commit-msg"), + ) + + if installed_hook_file.exists(): + async with aiofiles.open(installed_hook_file) as f: + data_installed = await f.read() + async with aiofiles.open(new_hook_file) as f: + data_new = await f.read() + if data_installed == data_new: + console.log("Git commit-msg hook is up to date") + else: + console.print( + f"error: {installed_hook_file} differ from mergify_cli hook", + style="red", + ) + sys.exit(1) + + else: + console.log("Installation of git commit-msg hook") + shutil.copy(new_hook_file, installed_hook_file) + installed_hook_file.chmod(0o755) diff --git a/mergify_cli/tests/test_mergify_cli.py b/mergify_cli/tests/test_mergify_cli.py index c84967a..2a443f3 100644 --- a/mergify_cli/tests/test_mergify_cli.py +++ b/mergify_cli/tests/test_mergify_cli.py @@ -22,7 +22,9 @@ import pytest import respx -import mergify_cli +from mergify_cli import cli +from mergify_cli import utils +from mergify_cli.stack import push from mergify_cli.tests import utils as test_utils @@ -79,14 +81,14 @@ def git_mock( output="", ) - with mock.patch("mergify_cli.git", git_mock_object): + with mock.patch("mergify_cli.utils.git", git_mock_object): yield git_mock_object @pytest.mark.usefixtures("_git_repo") async def test_cli_help(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit, match="0"): - await mergify_cli.parse_args(["--help"]) + await cli.parse_args(["--help"]) stdout = capsys.readouterr().out assert "usage: " in stdout @@ -96,22 +98,22 @@ async def test_cli_help(capsys: pytest.CaptureFixture[str]) -> None: @pytest.mark.usefixtures("_git_repo") async def test_get_branch_name() -> None: - assert await mergify_cli.git_get_branch_name() == "main" + assert await utils.git_get_branch_name() == "main" @pytest.mark.usefixtures("_git_repo") async def test_get_target_branch() -> None: - assert await mergify_cli.git_get_target_branch("main") == "main" + assert await utils.git_get_target_branch("main") == "main" @pytest.mark.usefixtures("_git_repo") async def test_get_target_remote() -> None: - assert await mergify_cli.git_get_target_remote("main") == "origin" + assert await utils.git_get_target_remote("main") == "origin" @pytest.mark.usefixtures("_git_repo") async def test_get_trunk() -> None: - assert await mergify_cli.get_trunk() == "origin/main" + assert await utils.get_trunk() == "origin/main" @pytest.mark.parametrize( @@ -124,7 +126,7 @@ async def test_get_trunk() -> None: ) def test_check_local_branch_valid(valid_branch_name: str) -> None: # Should not raise an error - mergify_cli.check_local_branch( + push.check_local_branch( branch_name=valid_branch_name, branch_prefix="prefix", ) @@ -132,10 +134,10 @@ def test_check_local_branch_valid(valid_branch_name: str) -> None: def test_check_local_branch_invalid() -> None: with pytest.raises( - mergify_cli.LocalBranchInvalidError, + push.LocalBranchInvalidError, match="Local branch is a branch generated by Mergify CLI", ): - mergify_cli.check_local_branch( + push.check_local_branch( branch_name="prefix/my-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", branch_prefix="prefix", ) @@ -212,7 +214,7 @@ async def test_stack_create( 200, ) - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=False, @@ -304,7 +306,7 @@ async def test_stack_create_single_pull( ) respx_mock.get("/repos/user/repo/issues/1/comments").respond(200, json=[]) - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=False, @@ -394,7 +396,7 @@ async def test_stack_update_no_rebase( ) respx_mock.patch("/repos/user/repo/issues/comments/456").respond(200) - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=True, @@ -484,7 +486,7 @@ async def test_stack_update( ) respx_mock.patch("/repos/user/repo/issues/comments/456").respond(200) - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=False, @@ -573,7 +575,7 @@ async def test_stack_update_keep_title_and_body( ) respx_mock.patch("/repos/user/repo/issues/comments/456").respond(200) - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=False, @@ -602,7 +604,7 @@ async def test_stack_on_destination_branch_raises_an_error( git_mock.mock("rev-parse", "--abbrev-ref", "HEAD", output="main") with pytest.raises(SystemExit, match="1"): - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=False, @@ -622,7 +624,7 @@ async def test_stack_without_common_commit_raises_an_error( git_mock.mock("merge-base", "--fork-point", "origin/main", output="") with pytest.raises(SystemExit, match="1"): - await mergify_cli.stack_push( + await push.stack_push( github_server="https://api.github.com/", token="", skip_rebase=False, @@ -636,9 +638,9 @@ async def test_stack_without_common_commit_raises_an_error( @pytest.mark.parametrize( ("default_arg_fct", "config_get_result", "expected_default"), [ - (mergify_cli.get_default_keep_pr_title_body, "true", True), + (cli.get_default_keep_pr_title_body, "true", True), ( - lambda: mergify_cli.get_default_branch_prefix("author"), + lambda: utils.get_default_branch_prefix("author"), "dummy-prefix", "dummy-prefix", ), @@ -652,7 +654,7 @@ async def test_defaults_config_args_set( config_get_result: bytes, expected_default: bool, ) -> None: - with mock.patch.object(mergify_cli, "_run_command", return_value=config_get_result): + with mock.patch.object(utils, "run_command", return_value=config_get_result): assert (await default_arg_fct()) == expected_default @@ -669,7 +671,7 @@ async def test_default( args: list[str], ) -> None: git_mock.default_cli_args() - parsed = await mergify_cli.parse_args(args) + parsed = await cli.parse_args(args) assert parsed.action == "stack" assert parsed.stack_action == "push" assert parsed.skip_rebase @@ -679,6 +681,6 @@ async def test_parse_edit( git_mock: test_utils.GitMock, ) -> None: git_mock.default_cli_args() - parsed = await mergify_cli.parse_args(["stack", "edit"]) + parsed = await cli.parse_args(["stack", "edit"]) assert parsed.action == "stack" assert parsed.stack_action == "edit" diff --git a/mergify_cli/utils.py b/mergify_cli/utils.py new file mode 100644 index 0000000..7881b0d --- /dev/null +++ b/mergify_cli/utils.py @@ -0,0 +1,193 @@ +# +# Copyright © 2021-2024 Mergify SAS +# +# Licensed 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 asyncio +import dataclasses +import sys +import typing +from urllib import parse + +import httpx + +from mergify_cli import VERSION +from mergify_cli import console + + +_DEBUG = False + + +def set_debug(debug: bool) -> None: + global _DEBUG # noqa: PLW0603 + _DEBUG = debug + + +def is_debug() -> bool: + return _DEBUG + + +async def check_for_status(response: httpx.Response) -> None: + if response.status_code < 400: + return + + if response.status_code < 500: + await response.aread() + data = response.json() + console.print(f"url: {response.request.url}", style="red") + console.print(f"data: {response.request.content.decode()}", style="red") + console.print( + f"HTTPError {response.status_code}: {data['message']}", + style="red", + ) + if "errors" in data: + console.print( + "\n".join(f"* {e.get('message') or e}" for e in data["errors"]), + style="red", + ) + sys.exit(1) + + response.raise_for_status() + + +@dataclasses.dataclass +class CommandError(Exception): + command_args: tuple[str, ...] + returncode: int | None + stdout: bytes + + def __str__(self) -> str: + return f"failed to run `{' '.join(self.command_args)}`: {self.stdout.decode()}" + + +async def run_command(*args: str) -> str: + if is_debug(): + console.print(f"[purple]DEBUG: running: git {' '.join(args)} [/]") + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0: + raise CommandError(args, proc.returncode, stdout) + return stdout.decode().strip() + + +async def git(*args: str) -> str: + return await run_command("git", *args) + + +async def git_get_branch_name() -> str: + return await git("rev-parse", "--abbrev-ref", "HEAD") + + +async def git_get_target_branch(branch: str) -> str: + return (await git("config", "--get", "branch." + branch + ".merge")).removeprefix( + "refs/heads/", + ) + + +async def git_get_target_remote(branch: str) -> str: + return await git("config", "--get", "branch." + branch + ".remote") + + +async def get_default_branch_prefix(author: str) -> str: + try: + result = await git("config", "--get", "mergify-cli.stack-branch-prefix") + except CommandError: + result = "" + + return result or f"stack/{author}" + + +async def get_trunk() -> str: + try: + branch_name = await git_get_branch_name() + except CommandError: + console.print("error: can't get the current branch", style="red") + raise + try: + target_branch = await git_get_target_branch(branch_name) + except CommandError: + # It's possible this has not been set; ignore + console.print("error: can't get the remote target branch", style="red") + console.print( + f"Please set the target branch with `git branch {branch_name} --set-upstream-to=/", + style="red", + ) + raise + + try: + target_remote = await git_get_target_remote(branch_name) + except CommandError: + console.print( + f"error: can't get the target remote for branch {branch_name}", + style="red", + ) + raise + return f"{target_remote}/{target_branch}" + + +def get_slug(url: str) -> tuple[str, str]: + parsed = parse.urlparse(url) + if not parsed.netloc: + # Probably ssh + _, _, path = parsed.path.partition(":") + else: + path = parsed.path[1:].rstrip("/") + + user, repo = path.split("/", 1) + repo = repo.removesuffix(".git") + return user, repo + + +# NOTE: must be async for httpx +async def log_httpx_request(request: httpx.Request) -> None: # noqa: RUF029 + console.print( + f"[purple]DEBUG: request: {request.method} {request.url} - Waiting for response[/]", + ) + + +# NOTE: must be async for httpx +async def log_httpx_response(response: httpx.Response) -> None: + request = response.request + await response.aread() + elapsed = response.elapsed.total_seconds() + console.print( + f"[purple]DEBUG: response: {request.method} {request.url} - Status {response.status_code} - Elasped {elapsed} s[/]", + ) + + +def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: + event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = { + "request": [], + "response": [check_for_status], + } + if is_debug(): + event_hooks["request"].insert(0, log_httpx_request) + event_hooks["response"].insert(0, log_httpx_response) + + return httpx.AsyncClient( + base_url=github_server, + headers={ + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"mergify_cli/{VERSION}", + "Authorization": f"token {token}", + }, + event_hooks=event_hooks, + follow_redirects=True, + timeout=5.0, + ) diff --git a/pyproject.toml b/pyproject.toml index 9ba6760..eff15b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ respx = ">=0.20.2,<0.22.0" types-aiofiles = ">=23.2.0.20240106,<25.0.0.0" [tool.poetry.scripts] -mergify = 'mergify_cli:cli' +mergify = 'mergify_cli.cli:main' [tool.pytest.ini_options] asyncio_mode = "auto"