Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ gh-llm pr checks --pr 77900 --repo PaddlePaddle/Paddle --all
gh-llm pr conflict-files --pr 77971 --repo PaddlePaddle/Paddle
```

### PR Body Scaffold

```bash
# Load the repo PR template (when present), append required sections, and write a body file
# The command also prints a ready-to-run `gh pr create --body-file ...` command.
gh-llm pr body-template --repo ShigureLab/watchfs --title 'feat: add watcher summary'
Comment thread
ShigureNyako marked this conversation as resolved.

gh-llm pr body-template \
--repo ShigureLab/watchfs \
--requirements 'Motivation,Validation,Related Issues' \
--output /tmp/pr_body.md
```

If the repo has no PR template, `gh-llm` falls back to a simple editable scaffold.
The bundled `skills/github-conversation/SKILL.md` also documents this workflow for skill users.

### Issue Reading

```bash
Expand Down
13 changes: 13 additions & 0 deletions skills/github-conversation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ gh-llm pr review-expand <PRR_id[,PRR_id...]> --pr <pr> --repo <owner/repo>
gh-llm pr checks --pr <pr> --repo <owner/repo>
```

### Prepare a PR body

```bash
gh-llm pr body-template --repo <owner/repo>

gh-llm pr body-template \
--repo <owner/repo> \
--requirements 'Motivation,Validation,Related Issues' \
--output /tmp/pr_body.md
```

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.

### Read an issue

```bash
Expand Down
73 changes: 73 additions & 0 deletions src/gh_llm/commands/pr.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import json
import os
import re
import shlex
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
Expand All @@ -11,6 +15,7 @@
from gh_llm.invocation import display_command_with
from gh_llm.models import PullRequestDiffPage
from gh_llm.pager import DEFAULT_PAGE_SIZE, TimelinePager
from gh_llm.pr_body import build_pull_request_body_scaffold, parse_required_sections
from gh_llm.render import (
render_checks_section,
render_comment_node_detail,
Expand Down Expand Up @@ -159,6 +164,24 @@ def register_pr_parser(subparsers: Any) -> None:
checks_parser.add_argument("--all", action="store_true", help="show all checks including passed")
checks_parser.set_defaults(handler=cmd_pr_checks)

body_template_parser = pr_subparsers.add_parser(
"body-template",
help="load a repo PR template, fill missing required sections, and write an editable body scaffold",
)
body_template_parser.add_argument("--repo", required=True, help="repository in OWNER/REPO format")
body_template_parser.add_argument("--title", help="optional PR title used in the suggested `gh pr create` command")
body_template_parser.add_argument(
"--requirements",
action="append",
default=[],
help="required body sections, comma-separated or repeatable",
)
body_template_parser.add_argument(
"--output",
help="write the scaffold to this file (defaults to a temporary .md file)",
)
body_template_parser.set_defaults(handler=cmd_pr_body_template)

conflicts_parser = pr_subparsers.add_parser(
"conflict-files",
help="detect and show conflicted files for a PR (on demand; may take longer on large repos)",
Expand Down Expand Up @@ -578,6 +601,56 @@ def cmd_pr_checks(args: Any) -> int:
return 0


def cmd_pr_body_template(args: Any) -> int:
client = GitHubClient()
repo = str(args.repo)
required_sections = parse_required_sections(list(getattr(args, "requirements", [])))
template_path, template_text = client.fetch_pull_request_template(repo)
scaffold = build_pull_request_body_scaffold(template_text, required_sections=required_sections)
output_path = _resolve_pr_body_output_path(getattr(args, "output", None))
output_path.write_text(scaffold.body, encoding="utf-8")

title = str(args.title).strip() if getattr(args, "title", None) else ""
quoted_repo = shlex.quote(repo)
quoted_output_path = shlex.quote(str(output_path))
quoted_title = shlex.quote(title) if title else "'<pr_title>'"

print("## PR Body Scaffold")
print(f"repo: {repo}")
print(f"template_found: {'true' if template_path else 'false'}")
print(f"template_path: {template_path or '(none)'}")
print(f"output_file: {output_path}")
print(f"required_sections: {json.dumps(required_sections, ensure_ascii=False)}")
print(f"added_sections: {json.dumps(list(scaffold.added_sections), ensure_ascii=False)}")
print()
print("## Body")
print("<pr_body>")
print(scaffold.body.rstrip())
print("</pr_body>")
print()
print("## Actions")
if not title:
print("⌨ pr_title: '<pr_title>'")
print(f"⏎ Edit scaffold file: `{quoted_output_path}`")
print(
f"⏎ Create PR via gh: `gh pr create --repo {quoted_repo} --title {quoted_title} --body-file {quoted_output_path}`"
)
return 0


def _resolve_pr_body_output_path(raw_output: object) -> Path:
if raw_output is None:
fd, temp_path = tempfile.mkstemp(prefix="gh-llm-pr-body-", suffix=".md")
os.close(fd)
return Path(temp_path)

path = Path(str(raw_output)).expanduser()
if path.exists() and path.is_dir():
raise RuntimeError(f"output path is a directory: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
return path


def cmd_pr_conflict_files(args: Any) -> int:
client = GitHubClient()
meta = _resolve_pr_meta(client=client, args=args)
Expand Down
201 changes: 191 additions & 10 deletions src/gh_llm/github_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import base64
import binascii
import json
import re
import subprocess
Expand Down Expand Up @@ -1391,23 +1392,152 @@ def fetch_file_lines(self, ref: PullRequestRef, *, path: str, revision: str) ->
self._file_lines_cache[cache_key] = None
return None

encoding = _as_optional_str(payload.get("encoding"))
content = _as_optional_str(payload.get("content"))
if encoding != "base64" or content is None:
self._file_lines_cache[cache_key] = None
return None

normalized = content.replace("\n", "")
try:
decoded = base64.b64decode(normalized, validate=False).decode("utf-8", errors="replace")
except (ValueError, UnicodeDecodeError):
decoded = _decode_repository_contents_text(payload)
if decoded is None:
self._file_lines_cache[cache_key] = None
return None

lines = tuple(decoded.splitlines())
self._file_lines_cache[cache_key] = lines
return lines

def fetch_pull_request_template(self, repo: str) -> tuple[str | None, str | None]:
owner, name = _parse_repo_full_name(repo)
direct_candidates = (
".github/PULL_REQUEST_TEMPLATE.md",
".github/pull_request_template.md",
".github/PULL_REQUEST_TEMPLATE.txt",
".github/pull_request_template.txt",
"PULL_REQUEST_TEMPLATE.md",
"pull_request_template.md",
"PULL_REQUEST_TEMPLATE.txt",
"pull_request_template.txt",
"docs/PULL_REQUEST_TEMPLATE.md",
"docs/pull_request_template.md",
"docs/PULL_REQUEST_TEMPLATE.txt",
"docs/pull_request_template.txt",
)
for candidate in direct_candidates:
text = self._fetch_repository_text_file(owner=owner, name=name, path=candidate)
if text is not None:
return candidate, text

for parent_path in _PULL_REQUEST_TEMPLATE_PARENT_PATHS:
candidate = self._find_direct_pull_request_template_via_listing(
owner=owner,
name=name,
parent_path=parent_path,
)
if candidate is None:
continue
text = self._fetch_repository_text_file(owner=owner, name=name, path=candidate)
if text is not None:
return candidate, text

seen_directories: set[str] = set()
for parent_path in _PULL_REQUEST_TEMPLATE_PARENT_PATHS:
for directory in self._list_pull_request_template_directories(
owner=owner,
name=name,
parent_path=parent_path,
):
if directory in seen_directories:
continue
seen_directories.add(directory)
for candidate in self._list_repository_template_files(owner=owner, name=name, path=directory):
text = self._fetch_repository_text_file(owner=owner, name=name, path=candidate)
if text is not None:
return candidate, text

return None, None
Comment thread
ShigureNyako marked this conversation as resolved.

def _fetch_repository_text_file(self, *, owner: str, name: str, path: str) -> str | None:
api_path = _build_repository_contents_api_path(owner=owner, name=name, path=path)
try:
payload = _run_command_json(
["gh", "api", api_path],
max_attempts=GRAPHQL_MAX_ATTEMPTS,
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
)
except RuntimeError:
return None
Comment thread
ShigureNyako marked this conversation as resolved.
Outdated

if (_as_optional_str(payload.get("type")) or "") != "file":
return None
return _decode_repository_contents_text(payload)

def _find_direct_pull_request_template_via_listing(
self,
*,
owner: str,
name: str,
parent_path: str,
) -> str | None:
candidates: list[str] = []
for entry in self._list_repository_contents(owner=owner, name=name, path=parent_path):
if (_as_optional_str(entry.get("type")) or "") != "file":
continue
entry_name = _as_optional_str(entry.get("name")) or ""
if not _is_direct_pull_request_template_name(entry_name):
continue
candidate_path = _as_optional_str(entry.get("path")) or ""
if candidate_path:
candidates.append(candidate_path)
if not candidates:
return None
return sorted(candidates, key=str.casefold)[0]

def _list_pull_request_template_directories(
self,
*,
owner: str,
name: str,
parent_path: str,
) -> tuple[str, ...]:
candidates: list[str] = []
for entry in self._list_repository_contents(owner=owner, name=name, path=parent_path):
if (_as_optional_str(entry.get("type")) or "") != "dir":
continue
entry_name = _as_optional_str(entry.get("name")) or ""
if not _is_pull_request_template_directory_name(entry_name):
continue
candidate_path = _as_optional_str(entry.get("path")) or ""
if candidate_path:
candidates.append(candidate_path)
return tuple(sorted(candidates, key=str.casefold))

def _list_repository_contents(self, *, owner: str, name: str, path: str) -> tuple[dict[str, object], ...]:
api_path = _build_repository_contents_api_path(owner=owner, name=name, path=path)
try:
payload = _run_command_json_any(
["gh", "api", api_path],
max_attempts=GRAPHQL_MAX_ATTEMPTS,
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
)
except RuntimeError:
return ()

entries: list[dict[str, object]] = []
for raw_entry in _as_list(payload):
entry = _as_dict_optional(raw_entry)
if entry is None:
continue
entries.append(entry)
return tuple(entries)

def _list_repository_template_files(self, *, owner: str, name: str, path: str) -> tuple[str, ...]:
candidates: list[str] = []
for entry in self._list_repository_contents(owner=owner, name=name, path=path):
if (_as_optional_str(entry.get("type")) or "") != "file":
continue
candidate_path = _as_optional_str(entry.get("path")) or ""
if not _is_pull_request_template_path(candidate_path):
continue
candidates.append(candidate_path)
return tuple(sorted(candidates, key=str.casefold))

def submit_pull_request_review(
self,
*,
Expand Down Expand Up @@ -2257,6 +2387,57 @@ def _parse_owner_repo(pr_url: str) -> tuple[str, str]:
return parts[0], parts[1]


def _parse_repo_full_name(repo: str) -> tuple[str, str]:
owner, separator, name = repo.strip().partition("/")
if not owner or not separator or not name:
raise RuntimeError(f"invalid repo format: {repo}. Expected OWNER/REPO")
return owner, name


_PULL_REQUEST_TEMPLATE_PARENT_PATHS = (".github", "", "docs")
_DIRECT_PULL_REQUEST_TEMPLATE_FILENAMES = frozenset(
{
"pull_request_template.md",
"pull_request_template.markdown",
"pull_request_template.mdown",
"pull_request_template.txt",
}
)


def _build_repository_contents_api_path(*, owner: str, name: str, path: str) -> str:
base = f"repos/{owner}/{name}/contents"
if not path:
return base
return f"{base}/{quote(path, safe='/')}"


def _decode_repository_contents_text(payload: dict[str, object]) -> str | None:
encoding = _as_optional_str(payload.get("encoding"))
content = _as_optional_str(payload.get("content"))
if encoding != "base64" or content is None:
return None

normalized = content.replace("\n", "")
try:
return base64.b64decode(normalized, validate=False).decode("utf-8", errors="replace")
except (binascii.Error, ValueError):
return None
Comment thread
ShigureNyako marked this conversation as resolved.


def _is_direct_pull_request_template_name(name: str) -> bool:
return name.casefold() in _DIRECT_PULL_REQUEST_TEMPLATE_FILENAMES


def _is_pull_request_template_directory_name(name: str) -> bool:
return name.casefold() == "pull_request_template"


def _is_pull_request_template_path(path: str) -> bool:
lowered = path.casefold()
return lowered.endswith((".md", ".markdown", ".mdown", ".txt"))


def _clip_text(text: str | None, fallback: str, limit: int = MAX_INLINE_TEXT) -> tuple[str, bool]:
if not text:
return fallback, False
Expand Down
Loading
Loading