Skip to content

Commit 6456de9

Browse files
committed
draft PR anchor
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 8c2c223 commit 6456de9

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

create_pr_from_issue.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python3
2+
"""Create a pull request from a GitHub issue using the ``ghapi`` package.
3+
4+
The PR inherits the issue's body (prefixed with ``Fixes #<issue>`` so GitHub
5+
links and auto-closes the issue), its labels, and its milestone, and is assigned
6+
to the authenticated user.
7+
8+
Requires the ``GITHUB_TOKEN`` (or ``GH_TOKEN``) environment variable.
9+
"""
10+
11+
12+
import argparse
13+
import asyncio
14+
import os
15+
from pathlib import Path
16+
import subprocess
17+
import sys
18+
19+
from ghapi.all import GhApi
20+
21+
DEFAULT_OWNER = "LFDT-Panurus"
22+
DEFAULT_REPO = "panurus"
23+
DEFAULT_BASE = "main"
24+
25+
26+
def subrun(*args, check=True, capture_output=True, text=True, **kwargs):
27+
proc = subprocess.run(*args, check=check,
28+
capture_output=capture_output, text=text, **kwargs)
29+
30+
if proc.stderr:
31+
print(proc.stderr)
32+
if proc.stdout:
33+
print(proc.stdout)
34+
35+
return proc
36+
37+
38+
def build_body(issue_body: str | None, issue_number: int) -> str:
39+
"""Prepend the ``Fixes #N`` linking line to the issue body."""
40+
fixes = f"Fixes #{issue_number}"
41+
body = (issue_body or "").strip()
42+
return f"{fixes}\n\n{body}" if body else fixes
43+
44+
45+
def create_branch(name: str, root: Path | None = None, remote: str = "origin"):
46+
subrun(["git", "branch", name],
47+
cwd=root or Path.cwd(), check=True)
48+
return name
49+
50+
51+
def add_dummy_commit(branch, root: Path | None = None):
52+
subrun(["git", "switch", "-C", branch],
53+
cwd=root or Path.cwd(), check=True)
54+
55+
subrun(["git", "commit", "--allow-empty", "--only", "-m",
56+
"draft PR anchor", "-s", "--", ":/"],
57+
cwd=root or Path.cwd(),
58+
check=True,
59+
capture_output=True,
60+
text=True)
61+
62+
subrun(["git", "push", "origin", branch],
63+
check=True, cwd=root or Path.cwd(),
64+
capture_output=True, text=True)
65+
66+
67+
def set_upstream(name: str, root: Path | None = None, remote: str = "origin") -> str:
68+
69+
subrun(["git", "push", "--set-upstream", remote, name],
70+
cwd=root or Path.cwd(), check=True)
71+
return name
72+
73+
74+
def make_worktree(branch, directory_name, main_branch_name: str = "main", root: Path | None = None):
75+
76+
subrun(["git", "switch", "-C", main_branch_name],
77+
cwd=root or Path.cwd(), check=True)
78+
subrun(["git", "worktree", "add", directory_name, branch],
79+
cwd=root or Path.cwd(), check=True)
80+
81+
82+
def parse_args() -> argparse.Namespace:
83+
parser = argparse.ArgumentParser(description=__doc__)
84+
parser.add_argument("issue", type=int,
85+
help="Issue number to base the PR on")
86+
parser.add_argument("--owner", default=DEFAULT_OWNER,
87+
help=f"Repo owner (default: {DEFAULT_OWNER})")
88+
parser.add_argument("--repo", default=DEFAULT_REPO,
89+
help=f"Repo name (default: {DEFAULT_REPO})")
90+
parser.add_argument("--base", default=DEFAULT_BASE,
91+
help=f"Base branch to merge into (default: {DEFAULT_BASE})")
92+
parser.add_argument("--title", default=None,
93+
help="PR title (default: the issue's title)")
94+
parser.add_argument("--main-branch-name", default="main",
95+
help="PR title (default: the issue's title)")
96+
97+
return parser.parse_args()
98+
99+
100+
def _get_token():
101+
return subprocess.run(["gh", "auth", "token"], capture_output=True, text=True).stdout.strip()
102+
103+
104+
def main(args) -> int:
105+
106+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get(
107+
"GH_TOKEN", _get_token())
108+
if not token:
109+
print("error: set GITHUB_TOKEN (or GH_TOKEN) with repo scope", file=sys.stderr)
110+
return 1
111+
112+
api = GhApi(owner=args.owner, repo=args.repo, token=token)
113+
114+
me = asyncio.run(api.users.get_authenticated()).login
115+
116+
issue = asyncio.run(api.issues.get(
117+
owner=args.owner, repo=args.repo, issue_number=args.issue))
118+
119+
labels = [lbl.name for lbl in (issue.labels or [])]
120+
milestone = issue.milestone.number if issue.milestone else None
121+
title = args.title or issue.title
122+
123+
branch = f"fix-{issue.number}"
124+
branch = create_branch(f"fix-{issue.number}")
125+
print("Created Branch:", branch)
126+
127+
print("Pushing Upstream branch:", branch)
128+
set_upstream(branch)
129+
130+
print("Adding dummy commit to:", branch)
131+
add_dummy_commit(branch)
132+
133+
# # Create the PR.
134+
print("Creating Draft PR:")
135+
pr = asyncio.run(api.pulls.create(
136+
owner=args.owner,
137+
repo=args.repo,
138+
title=title,
139+
head=branch,
140+
base=args.base,
141+
body=build_body(issue.body, args.issue),
142+
draft=True,
143+
))
144+
145+
print(f"created PR #{pr.number}: {pr.html_url}")
146+
147+
print("creating worktree for PR:", pr.number)
148+
make_worktree(
149+
branch=branch, directory_name=f"fix-{pr.number}", main_branch_name=args.main_branch_name)
150+
151+
# Labels, milestone and assignee live on the issue view of the PR.
152+
asyncio.run(api.issues.update(
153+
owner=args.owner,
154+
repo=args.repo,
155+
issue_number=pr.number,
156+
labels=labels,
157+
milestone=milestone,
158+
assignees=[me],
159+
))
160+
161+
print(f"assigned to @{me}")
162+
if labels:
163+
print(f"labels: {', '.join(labels)}")
164+
if milestone is not None:
165+
print(f"milestone: {issue.milestone.title} (#{milestone})")
166+
167+
return 0
168+
169+
170+
if __name__ == "__main__":
171+
args = parse_args()
172+
main(args)

0 commit comments

Comments
 (0)