Skip to content

Commit da07fb7

Browse files
committed
Initial commit
0 parents  commit da07fb7

File tree

15 files changed

+759
-0
lines changed

15 files changed

+759
-0
lines changed

.github/workflows/checks.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Checks
2+
on: [push, pull_request]
3+
permissions:
4+
contents: read
5+
6+
jobs:
7+
check:
8+
name: Code check
9+
if: "!contains(github.event.head_commit.message, 'ci skip all')"
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-python@v5
14+
- name: Install ruff
15+
run: python3 -m pip install ruff
16+
- name: Run lint check
17+
run: ruff check --output-format github .
18+
- name: Run format check
19+
run: ruff check --output-format github .

.pre-commit-config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
- id: check
5+
name: code check
6+
entry: hatch run check
7+
language: system
8+
types: [python]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Simon Sawicki
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# git-pr-helper
2+
3+
[![license](https://img.shields.io/badge/license-MIT-green)](https://github.com/Grub4K/git-pr-helper/blob/main/LICENSE)
4+
[![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)
5+
6+
Git subcommand to aid with GitHub PRs interaction
7+
8+
_**NOTE**_: While this software is still very early in development, it should already be useful.
9+
Don't expect speedy execution or good error handling for now though.
10+
11+
## Installation
12+
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`.
13+
14+
For easier and more convenient usage, you should create a git alias as well: `git config --global alias.pr "!git-pr-helper"`.
15+
After this you can invoke it conveniently via `git pr ...`. Use `git pr help` to access the help instead of `--help`.
16+
17+
## How does it work
18+
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.
19+
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`.
20+
21+
Additionally, GitHub provides `refs/pull/*/merge` for all open PRs.
22+
These can be used to determine if local PR branches can be removed (`prune`).
23+
24+
## Improvements
25+
These are in order of relevance
26+
- [ ] Error handling
27+
- [ ] Add a way to manage branch description
28+
- [ ] Use a simpler format for the branch description
29+
- [ ] Better input and output helpers and wrappers
30+
- [ ] Caching and lazy execution
31+
- [ ] Automated release CI
32+
- [ ] Do not hardcode `github.com`

git_pr_helper/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from __future__ import annotations
2+
3+
__version__ = "1.0.0"

git_pr_helper/__main__.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import subprocess
5+
import sys
6+
7+
from rich.console import Console
8+
9+
import git_pr_helper.actions
10+
11+
12+
def _main():
13+
import argparse
14+
15+
root_parser = argparse.ArgumentParser("git pr", add_help=False)
16+
# root_parser.add_argument(
17+
# "-C",
18+
# dest="path",
19+
# metavar="<path>",
20+
# help="set the base path for the git repository",
21+
# )
22+
subparsers = root_parser.add_subparsers(title="action", dest="action")
23+
24+
ALL_ACTIONS = {
25+
name.removeprefix("action_"): getattr(git_pr_helper.actions, name)
26+
for name in dir(git_pr_helper.actions)
27+
if name.startswith("action_")
28+
}
29+
30+
parsers = {}
31+
for name, module in ALL_ACTIONS.items():
32+
parser_args = getattr(module, "PARSER_ARGS", None) or {}
33+
parser = subparsers.add_parser(
34+
name, **parser_args, add_help=False, help=inspect.getdoc(module)
35+
)
36+
module.configure_parser(parser)
37+
parsers[name] = parser
38+
39+
parser = subparsers.add_parser("help", help="show this help message and exit")
40+
parser.add_argument(
41+
"subcommand",
42+
nargs="?",
43+
choices=[*ALL_ACTIONS, "help"],
44+
help="the subcommand to get help for",
45+
)
46+
parsers["help"] = parser
47+
48+
args = root_parser.parse_args()
49+
if args.action is None:
50+
root_parser.error("need to provide an action")
51+
elif args.action == "help":
52+
parser = parsers[args.subcommand] if args.subcommand else root_parser
53+
print(parser.format_help())
54+
exit(0)
55+
56+
runner = ALL_ACTIONS[args.action].run
57+
58+
console = Console()
59+
console.show_cursor(False)
60+
try:
61+
sys.exit(runner(console, args))
62+
63+
except subprocess.CalledProcessError as error:
64+
sys.exit(error.returncode)
65+
66+
finally:
67+
console.show_cursor()
68+
69+
70+
def main():
71+
try:
72+
_main()
73+
except KeyboardInterrupt:
74+
pass
75+
76+
77+
if __name__ == "__main__":
78+
main()

git_pr_helper/actions/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# ruff: noqa: F401
2+
from __future__ import annotations
3+
4+
from git_pr_helper.actions import action_add
5+
from git_pr_helper.actions import action_list
6+
from git_pr_helper.actions import action_prune
7+
from git_pr_helper.actions import action_push
8+
from git_pr_helper.actions import action_setup
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""add a new pr branch or convert aan existing branch to one"""
2+
3+
from __future__ import annotations
4+
5+
import typing
6+
7+
import rich.text
8+
9+
from git_pr_helper import styles
10+
from git_pr_helper.utils import PrBranchInfo
11+
from git_pr_helper.utils import git
12+
from git_pr_helper.utils import write_pr_branch_info
13+
14+
if typing.TYPE_CHECKING:
15+
import argparse
16+
17+
import rich.console
18+
19+
20+
def configure_parser(parser: argparse.ArgumentParser):
21+
parser.add_argument(
22+
"--prune",
23+
action="store_true",
24+
help="remove branches that have the same HEAD as the PR",
25+
)
26+
parser.add_argument(
27+
"pr",
28+
help="the pr to add, in the format <remote>#<number>",
29+
)
30+
31+
32+
def run(console: rich.console.Console, args: argparse.Namespace):
33+
pr_remote, _, pr_number = args.pr.rpartition("#")
34+
if not pr_remote:
35+
pr_remote = "*"
36+
37+
try:
38+
pr_number = str(int(pr_number))
39+
except ValueError:
40+
console.print(
41+
rich.text.Text.assemble(
42+
("error", styles.ERROR),
43+
": ",
44+
(pr_number, styles.ACCENT),
45+
" is not a number",
46+
)
47+
)
48+
return 1
49+
50+
head_hashes = [
51+
line.partition(" ")[::2]
52+
for line in git(
53+
"for-each-ref",
54+
"--format=%(objectname) %(refname:strip=2)",
55+
f"refs/remotes/{pr_remote}/pr/{pr_number}",
56+
)
57+
]
58+
if not head_hashes:
59+
console.print(
60+
rich.text.Text.assemble(
61+
("error", styles.ERROR),
62+
": did not find a matching pr",
63+
)
64+
)
65+
return 1
66+
elif len(head_hashes) > 1:
67+
console.print(
68+
rich.text.Text.assemble(
69+
("error", styles.ERROR),
70+
": found more than one PR: ",
71+
rich.text.Text(", ").join(
72+
rich.text.Text(pr_remote.replace("/pr/", "#"), style=styles.ACCENT)
73+
for _, pr_remote in head_hashes
74+
),
75+
)
76+
)
77+
return 1
78+
head_hash, pr_remote = head_hashes[0]
79+
pr_remote = pr_remote.partition("/")[0]
80+
81+
branches = git(
82+
"for-each-ref",
83+
"--points-at",
84+
head_hash,
85+
"--exclude",
86+
"refs/remotes/**/pr/*",
87+
"--format",
88+
"%(refname:strip=2)",
89+
"refs/remotes/**",
90+
)
91+
if len(branches) > 1:
92+
console.print(
93+
rich.text.Text.assemble(
94+
("More than one branch found", styles.ERROR),
95+
": ",
96+
rich.text.Text(", ").join(
97+
rich.text.Text(branch, style=styles.ACCENT) for branch in branches
98+
),
99+
)
100+
)
101+
branches = []
102+
103+
if branches:
104+
remote, _, branch = branches[0].partition("/")
105+
106+
else:
107+
console.show_cursor()
108+
info = console.input(
109+
rich.text.Text.assemble(
110+
"Enter branch info (",
111+
("<user>/<repo>", styles.ACCENT),
112+
":",
113+
("<branch>", styles.ACCENT),
114+
"): ",
115+
)
116+
)
117+
console.show_cursor(False)
118+
remote, _, branch = info.partition(":")
119+
if "/" in remote:
120+
remote = f"git@github.com:{remote}.git"
121+
122+
name = f"pr/{pr_number}"
123+
git("switch", "-c", name, "--track", f"{pr_remote}/{name}")
124+
write_pr_branch_info(name, PrBranchInfo(remote, branch, []))
125+
126+
if args.prune:
127+
branches = git(
128+
"for-each-ref",
129+
"--points-at",
130+
head_hash,
131+
"--exclude",
132+
"refs/heads/**/pr/*",
133+
"--format",
134+
"%(refname:strip=2)",
135+
"refs/heads/**",
136+
)
137+
if branches:
138+
git("branch", "-D", branch)
139+
console.print(
140+
rich.text.Text.assemble(
141+
"Pruned branches: ",
142+
rich.text.Text(", ").join(
143+
rich.text.Text(branch, style=styles.ACCENT)
144+
for branch in branches
145+
),
146+
)
147+
)
148+
149+
return 0

0 commit comments

Comments
 (0)