|
| 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", "commit", "--allow-empty", "--only", "-m", |
| 53 | + "draft PR anchor", "-s", "--", ":/"], |
| 54 | + cwd=root or Path.cwd(), |
| 55 | + check=True, |
| 56 | + capture_output=True, |
| 57 | + text=True) |
| 58 | + |
| 59 | + subrun(["git", "push", "origin", branch], |
| 60 | + check=True, cwd=root or Path.cwd(), |
| 61 | + capture_output=True, text=True) |
| 62 | + |
| 63 | + |
| 64 | +def set_upstream(name: str, root: Path | None = None, remote: str = "origin") -> str: |
| 65 | + |
| 66 | + subrun(["git", "push", "--set-upstream", remote, name], |
| 67 | + cwd=root or Path.cwd(), check=True) |
| 68 | + return name |
| 69 | + |
| 70 | + |
| 71 | +def parse_args() -> argparse.Namespace: |
| 72 | + parser = argparse.ArgumentParser(description=__doc__) |
| 73 | + parser.add_argument("issue", type=int, |
| 74 | + help="Issue number to base the PR on") |
| 75 | + parser.add_argument("--owner", default=DEFAULT_OWNER, |
| 76 | + help=f"Repo owner (default: {DEFAULT_OWNER})") |
| 77 | + parser.add_argument("--repo", default=DEFAULT_REPO, |
| 78 | + help=f"Repo name (default: {DEFAULT_REPO})") |
| 79 | + parser.add_argument("--base", default=DEFAULT_BASE, |
| 80 | + help=f"Base branch to merge into (default: {DEFAULT_BASE})") |
| 81 | + parser.add_argument("--title", default=None, |
| 82 | + help="PR title (default: the issue's title)") |
| 83 | + |
| 84 | + return parser.parse_args() |
| 85 | + |
| 86 | + |
| 87 | +def _get_token(): |
| 88 | + return subprocess.run(["gh", "auth", "token"], capture_output=True, text=True).stdout.strip() |
| 89 | + |
| 90 | + |
| 91 | +def main(args) -> int: |
| 92 | + |
| 93 | + token = os.environ.get("GITHUB_TOKEN") or os.environ.get( |
| 94 | + "GH_TOKEN", _get_token()) |
| 95 | + if not token: |
| 96 | + print("error: set GITHUB_TOKEN (or GH_TOKEN) with repo scope", file=sys.stderr) |
| 97 | + return 1 |
| 98 | + |
| 99 | + api = GhApi(owner=args.owner, repo=args.repo, token=token) |
| 100 | + |
| 101 | + me = asyncio.run(api.users.get_authenticated()).login |
| 102 | + |
| 103 | + issue = asyncio.run(api.issues.get( |
| 104 | + owner=args.owner, repo=args.repo, issue_number=args.issue)) |
| 105 | + |
| 106 | + labels = [lbl.name for lbl in (issue.labels or [])] |
| 107 | + milestone = issue.milestone.number if issue.milestone else None |
| 108 | + title = args.title or issue.title |
| 109 | + |
| 110 | + branch = f"fix-{issue.number}" |
| 111 | + # branch = create_branch(f"fix-{issue.number}") |
| 112 | + # print("Created Branch:", branch) |
| 113 | + |
| 114 | + # print("Pushing Upstream branch:", branch) |
| 115 | + # set_upstream(branch) |
| 116 | + |
| 117 | + print("Adding dummy commit to:", branch) |
| 118 | + add_dummy_commit(branch) |
| 119 | + |
| 120 | + # # Create the PR. |
| 121 | + print("Creating Draft PR:") |
| 122 | + pr = asyncio.run(api.pulls.create( |
| 123 | + owner=args.owner, |
| 124 | + repo=args.repo, |
| 125 | + title=title, |
| 126 | + head=branch, |
| 127 | + base=args.base, |
| 128 | + body=build_body(issue.body, args.issue), |
| 129 | + draft=True, |
| 130 | + )) |
| 131 | + |
| 132 | + print(f"created PR #{pr.number}: {pr.html_url}") |
| 133 | + |
| 134 | + # Labels, milestone and assignee live on the issue view of the PR. |
| 135 | + asyncio.run(api.issues.update( |
| 136 | + owner=args.owner, |
| 137 | + repo=args.repo, |
| 138 | + issue_number=pr.number, |
| 139 | + labels=labels, |
| 140 | + milestone=milestone, |
| 141 | + assignees=[me], |
| 142 | + )) |
| 143 | + |
| 144 | + print(f"assigned to @{me}") |
| 145 | + if labels: |
| 146 | + print(f"labels: {', '.join(labels)}") |
| 147 | + if milestone is not None: |
| 148 | + print(f"milestone: {issue.milestone.title} (#{milestone})") |
| 149 | + |
| 150 | + return 0 |
| 151 | + |
| 152 | + |
| 153 | +if __name__ == "__main__": |
| 154 | + args = parse_args() |
| 155 | + main(args) |
0 commit comments