diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml new file mode 100644 index 0000000..e5681ae --- /dev/null +++ b/.github/workflows/checks.yaml @@ -0,0 +1,19 @@ +name: Checks +on: [push, pull_request] +permissions: + contents: read + +jobs: + check: + name: Code check + if: "!contains(github.event.head_commit.message, 'ci skip all')" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - name: Install ruff + run: python3 -m pip install ruff + - name: Run lint check + run: ruff check --output-format github . + - name: Run format check + run: ruff check --output-format github . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f0d8d1e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: +- repo: local + hooks: + - id: check + name: code check + entry: hatch run check + language: system + types: [python] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7132a25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Simon Sawicki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5e5c9a --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# git-pr-helper + +[![license](https://img.shields.io/badge/license-MIT-green)](https://github.com/Grub4K/git-pr-helper/blob/main/LICENSE) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + +Git subcommand to aid with GitHub PRs interaction + +_**NOTE**_: While this software is still very early in development, it should already be useful. +Don't expect speedy execution or good error handling for now though. + +## Installation +For now, you have to install from git, e.g. `pipx install git+https://github.com/Grub4K/git-pr-helper.git`, and ensure its in `PATH`. + +For easier and more convenient usage, you should create a git alias as well: `git config --global alias.pr "!git-pr-helper"`. +After this you can invoke it conveniently via `git pr ...`. Use `git pr help` to access the help instead of `--help`. + +## How does it work +GitHub provides `refs/pull/*/head`, which we can use to get each PR; these are read only though, so maintainers or even the original authors cannot push to it. +To be able to push, we store the actual upstream pr remote in the branch description, and provide it explicitly: `git push git@github.com:user/repo.git HEAD:branch`. + +Additionally, GitHub provides `refs/pull/*/merge` for all open PRs. +These can be used to determine if local PR branches can be removed (`prune`). + +## Improvements +These are in order of relevance +- [ ] Error handling +- [ ] Add a way to manage branch description +- [ ] Use a simpler format for the branch description +- [ ] Better input and output helpers and wrappers +- [ ] Caching and lazy execution +- [ ] Automated release CI +- [ ] Do not hardcode `github.com` diff --git a/git_pr_helper/__init__.py b/git_pr_helper/__init__.py new file mode 100644 index 0000000..db76dcb --- /dev/null +++ b/git_pr_helper/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__version__ = "1.0.0" diff --git a/git_pr_helper/__main__.py b/git_pr_helper/__main__.py new file mode 100644 index 0000000..b91fe8e --- /dev/null +++ b/git_pr_helper/__main__.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import inspect +import subprocess +import sys + +from rich.console import Console + +import git_pr_helper.actions + + +def _main(): + import argparse + + root_parser = argparse.ArgumentParser("git pr", add_help=False) + # root_parser.add_argument( + # "-C", + # dest="path", + # metavar="", + # help="set the base path for the git repository", + # ) + subparsers = root_parser.add_subparsers(title="action", dest="action") + + ALL_ACTIONS = { + name.removeprefix("action_"): getattr(git_pr_helper.actions, name) + for name in dir(git_pr_helper.actions) + if name.startswith("action_") + } + + parsers = {} + for name, module in ALL_ACTIONS.items(): + parser_args = getattr(module, "PARSER_ARGS", None) or {} + parser = subparsers.add_parser( + name, **parser_args, add_help=False, help=inspect.getdoc(module) + ) + module.configure_parser(parser) + parsers[name] = parser + + parser = subparsers.add_parser("help", help="show this help message and exit") + parser.add_argument( + "subcommand", + nargs="?", + choices=[*ALL_ACTIONS, "help"], + help="the subcommand to get help for", + ) + parsers["help"] = parser + + args = root_parser.parse_args() + if args.action is None: + root_parser.error("need to provide an action") + elif args.action == "help": + parser = parsers[args.subcommand] if args.subcommand else root_parser + print(parser.format_help()) + exit(0) + + runner = ALL_ACTIONS[args.action].run + + console = Console() + console.show_cursor(False) + try: + sys.exit(runner(console, args)) + + except subprocess.CalledProcessError as error: + sys.exit(error.returncode) + + finally: + console.show_cursor() + + +def main(): + try: + _main() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/git_pr_helper/actions/__init__.py b/git_pr_helper/actions/__init__.py new file mode 100644 index 0000000..5da3ef3 --- /dev/null +++ b/git_pr_helper/actions/__init__.py @@ -0,0 +1,8 @@ +# ruff: noqa: F401 +from __future__ import annotations + +from git_pr_helper.actions import action_add +from git_pr_helper.actions import action_list +from git_pr_helper.actions import action_prune +from git_pr_helper.actions import action_push +from git_pr_helper.actions import action_setup diff --git a/git_pr_helper/actions/action_add.py b/git_pr_helper/actions/action_add.py new file mode 100644 index 0000000..82c374b --- /dev/null +++ b/git_pr_helper/actions/action_add.py @@ -0,0 +1,149 @@ +"""add a new pr branch or convert aan existing branch to one""" + +from __future__ import annotations + +import typing + +import rich.text + +from git_pr_helper import styles +from git_pr_helper.utils import PrBranchInfo +from git_pr_helper.utils import git +from git_pr_helper.utils import write_pr_branch_info + +if typing.TYPE_CHECKING: + import argparse + + import rich.console + + +def configure_parser(parser: argparse.ArgumentParser): + parser.add_argument( + "--prune", + action="store_true", + help="remove branches that have the same HEAD as the PR", + ) + parser.add_argument( + "pr", + help="the pr to add, in the format #", + ) + + +def run(console: rich.console.Console, args: argparse.Namespace): + pr_remote, _, pr_number = args.pr.rpartition("#") + if not pr_remote: + pr_remote = "*" + + try: + pr_number = str(int(pr_number)) + except ValueError: + console.print( + rich.text.Text.assemble( + ("error", styles.ERROR), + ": ", + (pr_number, styles.ACCENT), + " is not a number", + ) + ) + return 1 + + head_hashes = [ + line.partition(" ")[::2] + for line in git( + "for-each-ref", + "--format=%(objectname) %(refname:strip=2)", + f"refs/remotes/{pr_remote}/pr/{pr_number}", + ) + ] + if not head_hashes: + console.print( + rich.text.Text.assemble( + ("error", styles.ERROR), + ": did not find a matching pr", + ) + ) + return 1 + elif len(head_hashes) > 1: + console.print( + rich.text.Text.assemble( + ("error", styles.ERROR), + ": found more than one PR: ", + rich.text.Text(", ").join( + rich.text.Text(pr_remote.replace("/pr/", "#"), style=styles.ACCENT) + for _, pr_remote in head_hashes + ), + ) + ) + return 1 + head_hash, pr_remote = head_hashes[0] + pr_remote = pr_remote.partition("/")[0] + + branches = git( + "for-each-ref", + "--points-at", + head_hash, + "--exclude", + "refs/remotes/**/pr/*", + "--format", + "%(refname:strip=2)", + "refs/remotes/**", + ) + if len(branches) > 1: + console.print( + rich.text.Text.assemble( + ("More than one branch found", styles.ERROR), + ": ", + rich.text.Text(", ").join( + rich.text.Text(branch, style=styles.ACCENT) for branch in branches + ), + ) + ) + branches = [] + + if branches: + remote, _, branch = branches[0].partition("/") + + else: + console.show_cursor() + info = console.input( + rich.text.Text.assemble( + "Enter branch info (", + ("/", styles.ACCENT), + ":", + ("", styles.ACCENT), + "): ", + ) + ) + console.show_cursor(False) + remote, _, branch = info.partition(":") + if "/" in remote: + remote = f"git@github.com:{remote}.git" + + name = f"pr/{pr_number}" + git("switch", "-c", name, "--track", f"{pr_remote}/{name}") + write_pr_branch_info(name, PrBranchInfo(remote, branch, [])) + + if args.prune: + branches = git( + "for-each-ref", + "--points-at", + head_hash, + "--exclude", + "refs/heads/**/pr/*", + "--format", + "%(refname:strip=2)", + "refs/heads/**", + ) + if branches: + git("branch", "-D", branch) + console.print( + rich.text.Text.assemble( + "Pruned branches: ", + rich.text.Text(", ").join( + rich.text.Text(branch, style=styles.ACCENT) + for branch in branches + ), + ) + ) + + return 0 diff --git a/git_pr_helper/actions/action_list.py b/git_pr_helper/actions/action_list.py new file mode 100644 index 0000000..ba4a655 --- /dev/null +++ b/git_pr_helper/actions/action_list.py @@ -0,0 +1,125 @@ +"""list currently available pr branches""" + +from __future__ import annotations + +import dataclasses +import typing + +import rich.box +import rich.table +import rich.text + +from git_pr_helper.utils import abbreviate_remote +from git_pr_helper.utils import git +from git_pr_helper.utils import read_pr_branch_infos + +if typing.TYPE_CHECKING: + import argparse + + import rich.console + + +@dataclasses.dataclass +class BranchInfo: + is_head: bool + commit_hash: str + local: str + ahead_local: int + remote: str + ahead_remote: int + real_remote: str + real_branch: str + description: str | None + + +def configure_parser(parser: argparse.ArgumentParser): + parser.add_argument( + "pattern", + nargs="?", + help="optional pattern to filter for", + ) + + +def run(console: rich.console.Console, args: argparse.Namespace): + branches: dict[str, BranchInfo] = {} + pattern = args.pattern or "pr/*" + + pr_branch_infos = dict(read_pr_branch_infos(pattern)) + + output_format = " ".join( + [ + "%(if)%(HEAD)%(then)1%(else)0%(end)", + "%(objectname)", + "%(objectname:short)", + "%(refname:strip=2)", + "%(upstream:strip=2)", + ] + ).join(("%(if)%(upstream:short)%(then)", "%(end)")) + items = git( + "for-each-ref", + "--omit-empty", + f"--format={output_format}", + f"refs/heads/{pattern}", + ) + for item in items: + head, commit_full, commit, local, remote = item.split(" ") + ahead, _, behind = git( + "rev-list", + "--left-right", + "--count", + f"{commit_full}...{remote}", + )[0].partition("\t") + pr_branch_info = pr_branch_infos.get(local) + if not pr_branch_info: + continue + branches[local] = BranchInfo( + head == "1", + commit, + local, + int(ahead), + remote, + int(behind), + pr_branch_info.remote, + pr_branch_info.branch, + pr_branch_info.description[0] if pr_branch_info.description else None, + ) + + remote_config = git("config", "--get-regexp", "remote.*.url") + remotes = { + path.removeprefix("remote.").removesuffix(".url"): abbreviate_remote(remote) + for path, remote in map(str.split, remote_config) + } + + table = rich.table.Table(box=rich.box.SIMPLE) + table.add_column("", style="bold green") + table.add_column("commit", style="bright_yellow") + table.add_column("local", style="bright_cyan") + table.add_column("remote", style="bright_cyan") + table.add_column("push") + table.add_column("note", style="bright_white") + + for branch in branches.values(): + head = "*" if branch.is_head else " " + local = rich.text.Text(branch.local) + if branch.ahead_local: + local.append(f" +{branch.ahead_local}", "green") + remote_name, _, pr_number = branch.remote.split("/") + remote = rich.text.Text("") + remote.append( + f"{remote_name}#{pr_number}", + f"link https://github.com/{remotes[remote_name]}/pull/{pr_number}", + ) + if branch.ahead_remote: + remote.append(f" +{branch.ahead_remote}", "red") + + real_remote = abbreviate_remote(branch.real_remote) + push = rich.text.Text.assemble( + (real_remote, "bright_magenta"), + " @ ", + (branch.real_branch, "bright_cyan"), + ) + + table.add_row(head, branch.commit_hash, local, remote, push, branch.description) + + console.print(table) + return 0 diff --git a/git_pr_helper/actions/action_prune.py b/git_pr_helper/actions/action_prune.py new file mode 100644 index 0000000..068a8c7 --- /dev/null +++ b/git_pr_helper/actions/action_prune.py @@ -0,0 +1,64 @@ +"""prune PRs that are merged or closed""" + +from __future__ import annotations + +import argparse +import typing +from collections import defaultdict + +import rich.text + +from git_pr_helper import styles +from git_pr_helper.utils import git + +if typing.TYPE_CHECKING: + import rich.console + + +def configure_parser(parser: argparse.ArgumentParser): + parser.add_argument( + "--dry", + action="store_true", + help="do not remove, only list what would be removed", + ) + parser.add_argument( + "--soft", + action="store_true", + help="use -d instead of -D when deleting the branch", + ) + + +def run(console: rich.console.Console, args: argparse.Namespace): + remotes = defaultdict(set) + lines = git( + "for-each-ref", + "--format", + "%(refname:strip=-1) %(upstream:remotename)", + "refs/heads/pr/*", + ) + for line in lines: + pr, _, remote = line.partition(" ") + remotes[remote].add(pr) + + to_remove = [] + for remote, prs in remotes.items(): + lines = git("ls-remote", remote, "refs/pull/*/merge") + to_remove.extend(prs.difference(line.rsplit("/", 2)[1] for line in lines)) + + branches_to_remove = [f"pr/{pr_number}" for pr_number in to_remove] + if args.dry: + text = "Would remove: " + else: + text = "Pruned branches: " + git("branch", "-d" if args.soft else "-D", *branches_to_remove) + + console.print( + rich.text.Text.assemble( + text, + rich.text.Text(", ").join( + rich.text.Text(branch, style=styles.ACCENT) + for branch in branches_to_remove + ), + ) + ) + return 0 diff --git a/git_pr_helper/actions/action_push.py b/git_pr_helper/actions/action_push.py new file mode 100644 index 0000000..da46a84 --- /dev/null +++ b/git_pr_helper/actions/action_push.py @@ -0,0 +1,57 @@ +"""push a ref to the PRs origin branch""" + +from __future__ import annotations + +import argparse +import typing + +import rich.text + +from git_pr_helper import styles +from git_pr_helper.utils import git +from git_pr_helper.utils import read_pr_branch_infos + +if typing.TYPE_CHECKING: + import rich.console + + +# Workaround for `argparse.REMAINDER` +PARSER_ARGS = dict(prefix_chars="`") + + +def configure_parser(parser: argparse.ArgumentParser): + parser.add_argument(dest="remaining", nargs=argparse.REMAINDER) + + +def run(console: rich.console.Console, args: argparse.Namespace): + current_hash = git("rev-list", "-n", "1", "HEAD")[0] + head_ref, _, current_remote_ref = git( + "for-each-ref", + "--format=%(refname) %(upstream)", + "--points-at", + current_hash, + "refs/heads/**", + )[0].partition(" ") + current_branch = head_ref.removeprefix("refs/heads/") + current_remote, _, remote_ref = ( + current_remote_ref.partition("/")[2].partition("/")[2].partition("/") + ) + remote_ref = remote_ref.partition("/")[2] + + config = dict(read_pr_branch_infos()) + pr_branch_info = config.get(current_branch) + if not pr_branch_info: + console.print( + rich.text.Text.assemble( + ("error", styles.ERROR), + ": current branch is not a PR", + ) + ) + return 1 + + # TODO: lazy if already up to date + git("push", *args.remaining, pr_branch_info.remote, f"HEAD:{pr_branch_info.branch}") + + ref_spec = f"refs/pull/{remote_ref}/head:pr/{remote_ref}" + git("fetch", "--update-head-ok", current_remote, ref_spec) + return 0 diff --git a/git_pr_helper/actions/action_setup.py b/git_pr_helper/actions/action_setup.py new file mode 100644 index 0000000..0d5c2c7 --- /dev/null +++ b/git_pr_helper/actions/action_setup.py @@ -0,0 +1,38 @@ +"""setup prs for a specific remote""" + +from __future__ import annotations + +import re +import typing + +import rich.text + +from git_pr_helper import styles +from git_pr_helper.utils import git + +if typing.TYPE_CHECKING: + import argparse + + import rich.console + + +def configure_parser(parser: argparse.ArgumentParser): + parser.add_argument( + "remote", + nargs="?", + default="origin", + help="the remote to setup PRs on", + ) + + +def run(console: rich.console.Console, args: argparse.Namespace): + section = f"remote.{args.remote}.fetch" + value = f"+refs/pull/*/head:refs/remotes/{args.remote}/pr/*" + git("config", "--replace-all", section, value, f"^{re.escape(value)}$|^$") + console.print( + rich.text.Text.assemble( + "Successfully set up: ", + (args.remote, styles.ACCENT), + ) + ) + return 0 diff --git a/git_pr_helper/styles.py b/git_pr_helper/styles.py new file mode 100644 index 0000000..907ef68 --- /dev/null +++ b/git_pr_helper/styles.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import rich.style + +ACCENT = rich.style.Style(color="bright_cyan") +ERROR = rich.style.Style(color="red") diff --git a/git_pr_helper/utils.py b/git_pr_helper/utils.py new file mode 100644 index 0000000..d825ef3 --- /dev/null +++ b/git_pr_helper/utils.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import dataclasses +import fnmatch +import subprocess + +PR_BRANCH_PREFIX = "!!git pr" + + +def abbreviate_remote(remote: str): + return remove_around(remote, "git@github.com:", ".git") + + +def remove_around(data: str, before: str, after: str): + return data.removeprefix(before).removesuffix(after) + + +def git(*args: str): + null = False + + for arg in args: + if arg == "--null": + null = True + break + if arg == "--": + break + + command = ["git", *args] + output = subprocess.check_output(command, text=True) + if null: + return output.split("\x00") + return output.splitlines() + + +@dataclasses.dataclass +class PrBranchInfo: + remote: str + branch: str + description: list[str] + + +def read_pr_branch_infos(pattern: str | None = None): + if pattern: + re_pattern = remove_around(fnmatch.translate(pattern), "(?s:", ")\\Z") + else: + re_pattern = "[^.]*" + filter_pattern = rf"branch\.{re_pattern}\.description" + + descriptions = git("config", "--null", "--get-regexp", filter_pattern) + for description in descriptions: + name, _, description = description.partition("\n") + if not description.startswith(PR_BRANCH_PREFIX): + continue + name = remove_around(name, "branch.", ".description") + _, remote, branch, *description = description.split("\n") + yield name, PrBranchInfo(remote, branch, description) + + +def write_pr_branch_info(name: str, info: PrBranchInfo): + description = "\n".join( + [ + PR_BRANCH_PREFIX, + info.remote, + info.branch, + *info.description, + ] + ) + git("config", "--null", f"branch.{name}.description", description) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..da7ba90 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[project] +name = "git-pr-helper" +maintainers = [ + {name = "Grub4K", email = "contact@grub4k.xyz"}, +] +description = "Git subcommand to aid with GitHub PRs interaction" +readme = "README.md" +requires-python = ">=3.10" +keywords = [ + "git", + "git-plugin", +] +license = {file = "LICENSE"} +classifiers = [ + "Topic :: Multimedia :: Video", + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dynamic = ["version"] +dependencies = [ + "rich", +] + +[project.optional-dependencies] +dev = [ + "pre-commit", + "ruff~=0.2.0", + "pyinstaller~=6.0", +] + +[project.urls] +Documentation = "https://github.com/Grub4K/git-pr-helper?tab=readme-ov-file" +Repository = "https://github.com/Grub4K/git-pr-helper" +Tracker = "https://github.com/Grub4K/git-pr-helper/issues" +Funding = "https://github.com/sponsors/Grub4K" + +[project.scripts] +git-pr-helper = "git_pr_helper.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "git_pr_helper/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["git_pr_helper"] + +[tool.hatch.envs.default] +features = ["dev"] + +[tool.hatch.envs.default.scripts] +install = "pre-commit install" +fix = [ + "lint", + "format", +] +lint = "ruff check --fix {args:.}" +format = "ruff format {args:.}" +check = [ + "ruff check {args:.}", + "ruff format --check {args:.}", +] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +extend-select = [ + "I", +] + +[tool.ruff.lint.isort] +force-single-line = true +required-imports = ["from __future__ import annotations"]