Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ 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.

### Issue Reading

```bash
Expand Down
76 changes: 76 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,27 @@ 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",
"--requirement",
"--require-section",
Comment thread
ShigureNyako marked this conversation as resolved.
Outdated
dest="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 +604,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
101 changes: 91 additions & 10 deletions src/gh_llm/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1391,23 +1391,79 @@ 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",
"PULL_REQUEST_TEMPLATE.md",
"pull_request_template.md",
"docs/PULL_REQUEST_TEMPLATE.md",
"docs/pull_request_template.md",
)
Comment thread
ShigureNyako marked this conversation as resolved.
Outdated
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 directory in (".github/PULL_REQUEST_TEMPLATE", ".github/pull_request_template"):
for candidate in self._list_repository_template_files(owner=owner, name=name, path=directory):
Comment thread
ShigureNyako marked this conversation as resolved.
Outdated
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 = f"repos/{owner}/{name}/contents/{quote(path, safe='/')}"
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 _list_repository_template_files(self, *, owner: str, name: str, path: str) -> tuple[str, ...]:
api_path = f"repos/{owner}/{name}/contents/{quote(path, safe='/')}"
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 ()

candidates: list[str] = []
for raw_entry in _as_list(payload):
entry = _as_dict_optional(raw_entry)
if entry is None:
continue
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 +2313,31 @@ 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


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 ValueError:
return None
Comment thread
ShigureNyako marked this conversation as resolved.


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
104 changes: 104 additions & 0 deletions src/gh_llm/pr_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass

DEFAULT_BODY_SCAFFOLD_SECTIONS = (
"Motivation",
"Changes",
"Validation",
"Related Issues",
)

_HEADING_RE = re.compile(r"(?m)^\s{0,3}#{1,6}\s+(.*?)\s*$")
_MARKDOWN_LINK_RE = re.compile(r"\[(?P<label>[^\]]+)\]\([^)]*\)")


@dataclass(frozen=True)
class PullRequestBodyScaffold:
body: str
added_sections: tuple[str, ...]


def parse_required_sections(raw_values: list[str]) -> list[str]:
values: list[str] = []
seen: set[str] = set()

for raw in raw_values:
for part in raw.split(","):
section = part.strip()
if not section:
continue
normalized = normalize_section_title(section)
if normalized in seen:
continue
seen.add(normalized)
values.append(section)

return values


def normalize_section_title(value: str) -> str:
text = _MARKDOWN_LINK_RE.sub(lambda match: match.group("label"), value.strip())
text = text.strip("# ")
text = text.removesuffix(":").removesuffix(":")

chars: list[str] = []
for char in text.casefold():
if char.isspace():
continue
category = unicodedata.category(char)
if category.startswith(("L", "N")):
chars.append(char)
return "".join(chars)


def extract_markdown_section_titles(text: str) -> list[str]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里的 section 提取只识别 # 风格标题,没有覆盖 setext(标题\n----)这种模板写法。这样一来,--requirements 在已有 .txt / setext 模板上会重复追加段落。

我在当前 head 上实测:

uv run gh-llm pr body-template   --repo DocRaptor/docraptor-ruby   --requirements 'Why is this change needed?,Any screenshots?'

输出里会再次追加 ## Why is this change needed?## Any screenshots?,而这两个 section 在该仓库的 .github/pull_request_template.txt 里本来就已经存在。既然这个命令的目标是“补缺而不是重复”,这里至少需要补上 setext heading 的识别,并加一个对应的回归测试。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个我觉得还好……

titles: list[str] = []
for match in _HEADING_RE.finditer(text):
title = match.group(1).strip()
title = re.sub(r"\s+#+\s*$", "", title).strip()
if title:
titles.append(title)
return titles


def build_pull_request_body_scaffold(
template_text: str | None,
*,
required_sections: list[str],
) -> PullRequestBodyScaffold:
cleaned_template = (template_text or "").strip()
if not cleaned_template:
scaffold_sections = required_sections or list(DEFAULT_BODY_SCAFFOLD_SECTIONS)
return PullRequestBodyScaffold(
body=_render_section_scaffold(scaffold_sections),
added_sections=tuple(scaffold_sections),
)

existing_titles = {normalize_section_title(title) for title in extract_markdown_section_titles(cleaned_template)}
added_sections: list[str] = []
blocks = [cleaned_template]

for section in required_sections:
normalized = normalize_section_title(section)
if normalized in existing_titles:
continue
existing_titles.add(normalized)
added_sections.append(section)
blocks.append(_render_one_section(section))

return PullRequestBodyScaffold(
body="\n\n".join(block.rstrip() for block in blocks if block.strip()).rstrip() + "\n",
added_sections=tuple(added_sections),
)


def _render_section_scaffold(sections: list[str]) -> str:
blocks = [_render_one_section(section) for section in sections]
return "\n\n".join(blocks).rstrip() + "\n"


def _render_one_section(section: str) -> str:
return f"## {section}\n\n<!-- TODO: fill {section} -->"
Loading
Loading