Skip to content

Commit 29285f1

Browse files
✨ feat: add PR body scaffold command (#33)
Co-authored-by: Nyakku Shigure <38436475+SigureMo@users.noreply.github.com>
1 parent 4d717d2 commit 29285f1

7 files changed

Lines changed: 775 additions & 10 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,22 @@ gh-llm pr checks --pr 77900 --repo PaddlePaddle/Paddle --all
9797
gh-llm pr conflict-files --pr 77971 --repo PaddlePaddle/Paddle
9898
```
9999

100+
### PR Body Scaffold
101+
102+
```bash
103+
# Load the repo PR template (when present), append required sections, and write a body file
104+
# The command also prints a ready-to-run `gh pr create --body-file ...` command.
105+
gh-llm pr body-template --repo ShigureLab/watchfs --title 'feat: add watcher summary'
106+
107+
gh-llm pr body-template \
108+
--repo ShigureLab/watchfs \
109+
--requirements 'Motivation,Validation,Related Issues' \
110+
--output /tmp/pr_body.md
111+
```
112+
113+
If the repo has no PR template, `gh-llm` falls back to a simple editable scaffold.
114+
The bundled `skills/github-conversation/SKILL.md` also documents this workflow for skill users.
115+
100116
### Issue Reading
101117

102118
```bash

skills/github-conversation/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ gh-llm pr review-expand <PRR_id[,PRR_id...]> --pr <pr> --repo <owner/repo>
5959
gh-llm pr checks --pr <pr> --repo <owner/repo>
6060
```
6161

62+
### Prepare a PR body
63+
64+
```bash
65+
gh-llm pr body-template --repo <owner/repo>
66+
67+
gh-llm pr body-template \
68+
--repo <owner/repo> \
69+
--requirements 'Motivation,Validation,Related Issues' \
70+
--output /tmp/pr_body.md
71+
```
72+
73+
Use this before `gh pr create` when you need to load a repo PR template, append required sections, and produce a ready-to-edit body file.
74+
6275
### Read an issue
6376

6477
```bash

src/gh_llm/commands/pr.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from __future__ import annotations
22

3+
import json
4+
import os
35
import re
6+
import shlex
47
import sys
8+
import tempfile
59
from dataclasses import dataclass
610
from pathlib import Path
711
from typing import TYPE_CHECKING, Any
@@ -11,6 +15,7 @@
1115
from gh_llm.invocation import display_command_with
1216
from gh_llm.models import PullRequestDiffPage
1317
from gh_llm.pager import DEFAULT_PAGE_SIZE, TimelinePager, build_context_from_meta
18+
from gh_llm.pr_body import build_pull_request_body_scaffold, parse_required_sections
1419
from gh_llm.render import (
1520
render_checks_section,
1621
render_comment_node_detail,
@@ -159,6 +164,24 @@ def register_pr_parser(subparsers: Any) -> None:
159164
checks_parser.add_argument("--all", action="store_true", help="show all checks including passed")
160165
checks_parser.set_defaults(handler=cmd_pr_checks)
161166

167+
body_template_parser = pr_subparsers.add_parser(
168+
"body-template",
169+
help="load a repo PR template, fill missing required sections, and write an editable body scaffold",
170+
)
171+
body_template_parser.add_argument("--repo", required=True, help="repository in OWNER/REPO format")
172+
body_template_parser.add_argument("--title", help="optional PR title used in the suggested `gh pr create` command")
173+
body_template_parser.add_argument(
174+
"--requirements",
175+
action="append",
176+
default=[],
177+
help="required body sections, comma-separated or repeatable",
178+
)
179+
body_template_parser.add_argument(
180+
"--output",
181+
help="write the scaffold to this file (defaults to a temporary .md file)",
182+
)
183+
body_template_parser.set_defaults(handler=cmd_pr_body_template)
184+
162185
conflicts_parser = pr_subparsers.add_parser(
163186
"conflict-files",
164187
help="detect and show conflicted files for a PR (on demand; may take longer on large repos)",
@@ -585,6 +608,56 @@ def cmd_pr_checks(args: Any) -> int:
585608
return 0
586609

587610

611+
def cmd_pr_body_template(args: Any) -> int:
612+
client = GitHubClient()
613+
repo = str(args.repo)
614+
required_sections = parse_required_sections(list(getattr(args, "requirements", [])))
615+
template_path, template_text = client.fetch_pull_request_template(repo)
616+
scaffold = build_pull_request_body_scaffold(template_text, required_sections=required_sections)
617+
output_path = _resolve_pr_body_output_path(getattr(args, "output", None))
618+
output_path.write_text(scaffold.body, encoding="utf-8")
619+
620+
title = str(args.title).strip() if getattr(args, "title", None) else ""
621+
quoted_repo = shlex.quote(repo)
622+
quoted_output_path = shlex.quote(str(output_path))
623+
quoted_title = shlex.quote(title) if title else "'<pr_title>'"
624+
625+
print("## PR Body Scaffold")
626+
print(f"repo: {repo}")
627+
print(f"template_found: {'true' if template_path else 'false'}")
628+
print(f"template_path: {template_path or '(none)'}")
629+
print(f"output_file: {output_path}")
630+
print(f"required_sections: {json.dumps(required_sections, ensure_ascii=False)}")
631+
print(f"added_sections: {json.dumps(list(scaffold.added_sections), ensure_ascii=False)}")
632+
print()
633+
print("## Body")
634+
print("<pr_body>")
635+
print(scaffold.body.rstrip())
636+
print("</pr_body>")
637+
print()
638+
print("## Actions")
639+
if not title:
640+
print("⌨ pr_title: '<pr_title>'")
641+
print(f"⏎ Edit scaffold file: `{quoted_output_path}`")
642+
print(
643+
f"⏎ Create PR via gh: `gh pr create --repo {quoted_repo} --title {quoted_title} --body-file {quoted_output_path}`"
644+
)
645+
return 0
646+
647+
648+
def _resolve_pr_body_output_path(raw_output: object) -> Path:
649+
if raw_output is None:
650+
fd, temp_path = tempfile.mkstemp(prefix="gh-llm-pr-body-", suffix=".md")
651+
os.close(fd)
652+
return Path(temp_path)
653+
654+
path = Path(str(raw_output)).expanduser()
655+
if path.exists() and path.is_dir():
656+
raise RuntimeError(f"output path is a directory: {path}")
657+
path.parent.mkdir(parents=True, exist_ok=True)
658+
return path
659+
660+
588661
def cmd_pr_conflict_files(args: Any) -> int:
589662
client = GitHubClient()
590663
meta = _resolve_pr_meta(client=client, args=args)

src/gh_llm/github_api.py

Lines changed: 209 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import base64
4+
import binascii
45
import json
56
import re
67
import subprocess
@@ -1391,23 +1392,151 @@ def fetch_file_lines(self, ref: PullRequestRef, *, path: str, revision: str) ->
13911392
self._file_lines_cache[cache_key] = None
13921393
return None
13931394

1394-
encoding = _as_optional_str(payload.get("encoding"))
1395-
content = _as_optional_str(payload.get("content"))
1396-
if encoding != "base64" or content is None:
1397-
self._file_lines_cache[cache_key] = None
1398-
return None
1399-
1400-
normalized = content.replace("\n", "")
1401-
try:
1402-
decoded = base64.b64decode(normalized, validate=False).decode("utf-8", errors="replace")
1403-
except (ValueError, UnicodeDecodeError):
1395+
decoded = _decode_repository_contents_text(payload)
1396+
if decoded is None:
14041397
self._file_lines_cache[cache_key] = None
14051398
return None
14061399

14071400
lines = tuple(decoded.splitlines())
14081401
self._file_lines_cache[cache_key] = lines
14091402
return lines
14101403

1404+
def fetch_pull_request_template(self, repo: str) -> tuple[str | None, str | None]:
1405+
owner, name = _parse_repo_full_name(repo)
1406+
self._assert_repository_accessible(owner=owner, name=name)
1407+
for candidate in _iter_direct_pull_request_template_candidate_paths():
1408+
text = self._fetch_repository_text_file(owner=owner, name=name, path=candidate)
1409+
if text is not None:
1410+
return candidate, text
1411+
1412+
for parent_path in _PULL_REQUEST_TEMPLATE_PARENT_PATHS:
1413+
candidate = self._find_direct_pull_request_template_via_listing(
1414+
owner=owner,
1415+
name=name,
1416+
parent_path=parent_path,
1417+
)
1418+
if candidate is None:
1419+
continue
1420+
text = self._fetch_repository_text_file(owner=owner, name=name, path=candidate)
1421+
if text is not None:
1422+
return candidate, text
1423+
1424+
seen_directories: set[str] = set()
1425+
for parent_path in _PULL_REQUEST_TEMPLATE_PARENT_PATHS:
1426+
for directory in self._list_pull_request_template_directories(
1427+
owner=owner,
1428+
name=name,
1429+
parent_path=parent_path,
1430+
):
1431+
if directory in seen_directories:
1432+
continue
1433+
seen_directories.add(directory)
1434+
for candidate in self._list_repository_template_files(owner=owner, name=name, path=directory):
1435+
text = self._fetch_repository_text_file(owner=owner, name=name, path=candidate)
1436+
if text is not None:
1437+
return candidate, text
1438+
1439+
return None, None
1440+
1441+
def _assert_repository_accessible(self, *, owner: str, name: str) -> None:
1442+
_run_command_json(
1443+
["gh", "api", f"repos/{owner}/{name}"],
1444+
max_attempts=GRAPHQL_MAX_ATTEMPTS,
1445+
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
1446+
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
1447+
)
1448+
1449+
def _fetch_repository_text_file(self, *, owner: str, name: str, path: str) -> str | None:
1450+
api_path = _build_repository_contents_api_path(owner=owner, name=name, path=path)
1451+
try:
1452+
payload = _run_command_json(
1453+
["gh", "api", api_path],
1454+
max_attempts=GRAPHQL_MAX_ATTEMPTS,
1455+
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
1456+
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
1457+
)
1458+
except RuntimeError as error:
1459+
if _is_gh_api_not_found_error(str(error)):
1460+
return None
1461+
raise
1462+
1463+
if (_as_optional_str(payload.get("type")) or "") != "file":
1464+
return None
1465+
return _decode_repository_contents_text(payload)
1466+
1467+
def _find_direct_pull_request_template_via_listing(
1468+
self,
1469+
*,
1470+
owner: str,
1471+
name: str,
1472+
parent_path: str,
1473+
) -> str | None:
1474+
candidates: list[str] = []
1475+
for entry in self._list_repository_contents(owner=owner, name=name, path=parent_path):
1476+
if (_as_optional_str(entry.get("type")) or "") != "file":
1477+
continue
1478+
entry_name = _as_optional_str(entry.get("name")) or ""
1479+
if not _is_direct_pull_request_template_name(entry_name):
1480+
continue
1481+
candidate_path = _as_optional_str(entry.get("path")) or ""
1482+
if candidate_path:
1483+
candidates.append(candidate_path)
1484+
if not candidates:
1485+
return None
1486+
return sorted(candidates, key=str.casefold)[0]
1487+
1488+
def _list_pull_request_template_directories(
1489+
self,
1490+
*,
1491+
owner: str,
1492+
name: str,
1493+
parent_path: str,
1494+
) -> tuple[str, ...]:
1495+
candidates: list[str] = []
1496+
for entry in self._list_repository_contents(owner=owner, name=name, path=parent_path):
1497+
if (_as_optional_str(entry.get("type")) or "") != "dir":
1498+
continue
1499+
entry_name = _as_optional_str(entry.get("name")) or ""
1500+
if not _is_pull_request_template_directory_name(entry_name):
1501+
continue
1502+
candidate_path = _as_optional_str(entry.get("path")) or ""
1503+
if candidate_path:
1504+
candidates.append(candidate_path)
1505+
return tuple(sorted(candidates, key=str.casefold))
1506+
1507+
def _list_repository_contents(self, *, owner: str, name: str, path: str) -> tuple[dict[str, object], ...]:
1508+
api_path = _build_repository_contents_api_path(owner=owner, name=name, path=path)
1509+
try:
1510+
payload = _run_command_json_any(
1511+
["gh", "api", api_path],
1512+
max_attempts=GRAPHQL_MAX_ATTEMPTS,
1513+
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
1514+
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
1515+
)
1516+
except RuntimeError as error:
1517+
if _is_gh_api_not_found_error(str(error)):
1518+
return ()
1519+
raise
1520+
1521+
entries: list[dict[str, object]] = []
1522+
for raw_entry in _as_list(payload):
1523+
entry = _as_dict_optional(raw_entry)
1524+
if entry is None:
1525+
continue
1526+
entries.append(entry)
1527+
return tuple(entries)
1528+
1529+
def _list_repository_template_files(self, *, owner: str, name: str, path: str) -> tuple[str, ...]:
1530+
candidates: list[str] = []
1531+
for entry in self._list_repository_contents(owner=owner, name=name, path=path):
1532+
if (_as_optional_str(entry.get("type")) or "") != "file":
1533+
continue
1534+
candidate_path = _as_optional_str(entry.get("path")) or ""
1535+
if not _is_pull_request_template_path(candidate_path):
1536+
continue
1537+
candidates.append(candidate_path)
1538+
return tuple(sorted(candidates, key=str.casefold))
1539+
14111540
def submit_pull_request_review(
14121541
self,
14131542
*,
@@ -2257,6 +2386,76 @@ def _parse_owner_repo(pr_url: str) -> tuple[str, str]:
22572386
return parts[0], parts[1]
22582387

22592388

2389+
def _parse_repo_full_name(repo: str) -> tuple[str, str]:
2390+
owner, separator, name = repo.strip().partition("/")
2391+
if not owner or not separator or not name:
2392+
raise RuntimeError(f"invalid repo format: {repo}. Expected OWNER/REPO")
2393+
return owner, name
2394+
2395+
2396+
_PULL_REQUEST_TEMPLATE_PARENT_PATHS = (".github", "", "docs")
2397+
_DIRECT_PULL_REQUEST_TEMPLATE_BASENAME_VARIANTS = ("PULL_REQUEST_TEMPLATE", "pull_request_template")
2398+
_PULL_REQUEST_TEMPLATE_FILE_SUFFIXES = (".md", ".txt", ".markdown", ".mdown")
2399+
_DIRECT_PULL_REQUEST_TEMPLATE_FILENAMES = frozenset(
2400+
f"{basename}{suffix}".casefold()
2401+
for basename in _DIRECT_PULL_REQUEST_TEMPLATE_BASENAME_VARIANTS
2402+
for suffix in _PULL_REQUEST_TEMPLATE_FILE_SUFFIXES
2403+
)
2404+
2405+
2406+
def _build_repository_contents_api_path(*, owner: str, name: str, path: str) -> str:
2407+
base = f"repos/{owner}/{name}/contents"
2408+
if not path:
2409+
return base
2410+
return f"{base}/{quote(path, safe='/')}"
2411+
2412+
2413+
def _build_repository_relative_path(*, parent_path: str, child_name: str) -> str:
2414+
if not parent_path:
2415+
return child_name
2416+
return f"{parent_path}/{child_name}"
2417+
2418+
2419+
def _iter_direct_pull_request_template_candidate_paths() -> tuple[str, ...]:
2420+
return tuple(
2421+
_build_repository_relative_path(parent_path=parent_path, child_name=f"{basename}{suffix}")
2422+
for parent_path in _PULL_REQUEST_TEMPLATE_PARENT_PATHS
2423+
for basename in _DIRECT_PULL_REQUEST_TEMPLATE_BASENAME_VARIANTS
2424+
for suffix in _PULL_REQUEST_TEMPLATE_FILE_SUFFIXES
2425+
)
2426+
2427+
2428+
def _decode_repository_contents_text(payload: dict[str, object]) -> str | None:
2429+
encoding = _as_optional_str(payload.get("encoding"))
2430+
content = _as_optional_str(payload.get("content"))
2431+
if encoding != "base64" or content is None:
2432+
return None
2433+
2434+
normalized = content.replace("\n", "")
2435+
try:
2436+
return base64.b64decode(normalized, validate=False).decode("utf-8", errors="replace")
2437+
except (binascii.Error, ValueError):
2438+
return None
2439+
2440+
2441+
def _is_direct_pull_request_template_name(name: str) -> bool:
2442+
return name.casefold() in _DIRECT_PULL_REQUEST_TEMPLATE_FILENAMES
2443+
2444+
2445+
def _is_pull_request_template_directory_name(name: str) -> bool:
2446+
return name.casefold() == "pull_request_template"
2447+
2448+
2449+
def _is_pull_request_template_path(path: str) -> bool:
2450+
lowered = path.casefold()
2451+
return lowered.endswith((".md", ".markdown", ".mdown", ".txt"))
2452+
2453+
2454+
def _is_gh_api_not_found_error(message: str) -> bool:
2455+
lowered = message.casefold()
2456+
return "404" in lowered and "not found" in lowered
2457+
2458+
22602459
def _clip_text(text: str | None, fallback: str, limit: int = MAX_INLINE_TEXT) -> tuple[str, bool]:
22612460
if not text:
22622461
return fallback, False

0 commit comments

Comments
 (0)