Skip to content

Commit d5e4654

Browse files
authored
✨ feat: add --body-file support to PR review write actions (#34)
1 parent 29285f1 commit d5e4654

3 files changed

Lines changed: 190 additions & 8 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ gh-llm issue comment-edit IC_xxx --body '<new_body>' --issue 77924 --repo Paddle
145145

146146
# Reply / resolve / unresolve review thread
147147
gh-llm pr thread-reply PRRT_xxx --body '<reply>' --pr 77900 --repo PaddlePaddle/Paddle
148+
gh-llm pr thread-reply PRRT_xxx --body-file reply.md --pr 77900 --repo PaddlePaddle/Paddle
149+
cat reply.md | gh-llm pr thread-reply PRRT_xxx --body-file - --pr 77900 --repo PaddlePaddle/Paddle
148150
gh-llm pr thread-resolve PRRT_xxx --pr 77900 --repo PaddlePaddle/Paddle
149151
gh-llm pr thread-unresolve PRRT_xxx --pr 77900 --repo PaddlePaddle/Paddle
150152
```
@@ -188,6 +190,13 @@ gh-llm pr review-comment \
188190
--side RIGHT \
189191
--body 'Please add a regression test for duplicate keyword arguments.' \
190192
--pr 77938 --repo PaddlePaddle/Paddle
193+
194+
gh-llm pr review-comment \
195+
--path 'paddle/phi/api/include/compat/torch/library.h' \
196+
--line 106 \
197+
--side RIGHT \
198+
--body-file review-comment.md \
199+
--pr 77938 --repo PaddlePaddle/Paddle
191200
```
192201

193202
### 3) Add inline suggestion
@@ -200,6 +209,14 @@ gh-llm pr review-suggest \
200209
--body 'Suggested update' \
201210
--suggestion 'replacement_code_here' \
202211
--pr 77938 --repo PaddlePaddle/Paddle
212+
213+
gh-llm pr review-suggest \
214+
--path 'path/to/file' \
215+
--line 123 \
216+
--side RIGHT \
217+
--body-file suggestion-reason.md \
218+
--suggestion 'replacement_code_here' \
219+
--pr 77938 --repo PaddlePaddle/Paddle
203220
```
204221

205222
### 4) Submit review
@@ -216,6 +233,8 @@ gh-llm pr review-submit \
216233
--pr 77938 --repo PaddlePaddle/Paddle
217234
```
218235

236+
`thread-reply`, `review-comment`, `review-suggest`, and `review-submit` all support `--body-file -` to read multi-line text from standard input.
237+
219238
Submit behavior:
220239

221240
- If you already have a pending review on this PR, `review-submit` submits that pending review.

src/gh_llm/commands/pr.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,13 @@ def register_pr_parser(subparsers: Any) -> None:
192192

193193
thread_reply_parser = pr_subparsers.add_parser("thread-reply", help="reply to a pull request review thread")
194194
thread_reply_parser.add_argument("thread_id", help="review thread id, e.g. PRRT_xxx")
195-
thread_reply_parser.add_argument("--body", required=True, help="reply body")
195+
thread_reply_body_group = thread_reply_parser.add_mutually_exclusive_group(required=True)
196+
thread_reply_body_group.add_argument("--body", help="reply body")
197+
thread_reply_body_group.add_argument(
198+
"-F",
199+
"--body-file",
200+
help="read reply body from file (use `-` to read from standard input)",
201+
)
196202
thread_reply_parser.add_argument("--pr", help="PR number/url/branch")
197203
thread_reply_parser.add_argument("--repo", help="repository in OWNER/REPO format")
198204
thread_reply_parser.set_defaults(handler=cmd_pr_thread_reply)
@@ -279,7 +285,13 @@ def register_pr_parser(subparsers: Any) -> None:
279285
help="starting diff side for a multi-line range (defaults to --side)",
280286
)
281287
review_comment_parser.add_argument("--head", help="expected PR head sha for stale-snapshot protection")
282-
review_comment_parser.add_argument("--body", required=True, help="review comment body")
288+
review_comment_body_group = review_comment_parser.add_mutually_exclusive_group(required=True)
289+
review_comment_body_group.add_argument("--body", help="review comment body")
290+
review_comment_body_group.add_argument(
291+
"-F",
292+
"--body-file",
293+
help="read review comment body from file (use `-` to read from standard input)",
294+
)
283295
review_comment_parser.add_argument("--pr", help="PR number/url/branch")
284296
review_comment_parser.add_argument("--repo", help="repository in OWNER/REPO format")
285297
review_comment_parser.set_defaults(handler=cmd_pr_review_comment)
@@ -290,11 +302,17 @@ def register_pr_parser(subparsers: Any) -> None:
290302
review_suggest_parser.add_argument("--path", required=True, help="file path in pull request")
291303
review_suggest_parser.add_argument("--line", required=True, type=int, help="line number on selected side")
292304
review_suggest_parser.add_argument("--side", choices=["RIGHT", "LEFT"], default="RIGHT", help="diff side")
293-
review_suggest_parser.add_argument(
305+
review_suggest_body_group = review_suggest_parser.add_mutually_exclusive_group()
306+
review_suggest_body_group.add_argument(
294307
"--body",
295308
default="Suggested change",
296309
help="review comment body before suggestion block",
297310
)
311+
review_suggest_body_group.add_argument(
312+
"-F",
313+
"--body-file",
314+
help="read review comment body from file (use `-` to read from standard input)",
315+
)
298316
review_suggest_parser.add_argument(
299317
"--suggestion",
300318
required=True,
@@ -332,11 +350,18 @@ def _read_body_file(path: str) -> str:
332350
return Path(path).read_text(encoding="utf-8")
333351

334352

335-
def _resolve_review_submit_body(args: Any) -> str:
353+
def _resolve_body_argument(args: Any, *, default: str = "") -> str:
336354
body_file = getattr(args, "body_file", None)
337355
if body_file:
338356
return _read_body_file(str(body_file))
339-
return str(getattr(args, "body", ""))
357+
body = getattr(args, "body", None)
358+
if body is None:
359+
return default
360+
return str(body)
361+
362+
363+
def _resolve_review_submit_body(args: Any) -> str:
364+
return _resolve_body_argument(args)
340365

341366

342367
def cmd_pr_view(args: Any) -> int:
@@ -680,7 +705,8 @@ def cmd_pr_thread_reply(args: Any) -> int:
680705
if args.pr is not None:
681706
client.resolve_pull_request(selector=args.pr, repo=args.repo)
682707

683-
comment_id = client.reply_review_thread(thread_id=str(args.thread_id), body=str(args.body))
708+
body = _resolve_body_argument(args)
709+
comment_id = client.reply_review_thread(thread_id=str(args.thread_id), body=body)
684710
print(f"thread: {args.thread_id}")
685711
if comment_id:
686712
print(f"reply_comment_id: {comment_id}")
@@ -1046,7 +1072,7 @@ def cmd_pr_review_comment(args: Any) -> int:
10461072
side=str(args.side),
10471073
start_line=start_line,
10481074
start_side=start_side,
1049-
body=str(args.body),
1075+
body=_resolve_body_argument(args),
10501076
)
10511077
print(f"thread: {thread_id}")
10521078
if comment_id:
@@ -1071,7 +1097,8 @@ def cmd_pr_review_suggest(args: Any) -> int:
10711097
start_side=start_side,
10721098
)
10731099
suggestion = str(args.suggestion).rstrip("\n")
1074-
full_body = f"{str(args.body).rstrip()}\n\n```suggestion\n{suggestion}\n```"
1100+
body = _resolve_body_argument(args, default="Suggested change")
1101+
full_body = f"{body.rstrip()}\n\n```suggestion\n{suggestion}\n```"
10751102
thread_id, comment_id = client.add_pull_request_review_thread_comment(
10761103
ref=meta.ref,
10771104
path=str(args.path),

tests/test_cli.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,142 @@ def run_with_contents_failure(
20712071
assert not output_path.exists()
20722072

20732073

2074+
def test_pr_thread_reply_supports_body_file(
2075+
monkeypatch: pytest.MonkeyPatch,
2076+
tmp_path: Path,
2077+
capsys: pytest.CaptureFixture[str],
2078+
) -> None:
2079+
responder = GhResponder()
2080+
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
2081+
body_file = tmp_path / "reply.md"
2082+
body_file.write_text("> quoted context\n\nreply from file\n", encoding="utf-8")
2083+
2084+
code = cli.run(
2085+
[
2086+
"pr",
2087+
"thread-reply",
2088+
"PRRT_mock_1",
2089+
"--body-file",
2090+
str(body_file),
2091+
"--pr",
2092+
"77928",
2093+
"--repo",
2094+
"PaddlePaddle/Paddle",
2095+
]
2096+
)
2097+
2098+
assert code == 0
2099+
out = capsys.readouterr().out
2100+
assert "status: replied" in out
2101+
graphql_calls = [call for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
2102+
reply_call = next(
2103+
call for call in graphql_calls if "addPullRequestReviewThreadReply" in _extract_form(call, "query")
2104+
)
2105+
assert _extract_field(reply_call, "body") == "> quoted context\n\nreply from file\n"
2106+
2107+
2108+
def test_pr_review_comment_supports_body_file_stdin(
2109+
monkeypatch: pytest.MonkeyPatch,
2110+
capsys: pytest.CaptureFixture[str],
2111+
) -> None:
2112+
responder = GhResponder()
2113+
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
2114+
monkeypatch.setattr(sys, "stdin", _FakeStdin("stdin review comment\nwith second line\n"))
2115+
2116+
code = cli.run(
2117+
[
2118+
"pr",
2119+
"review-comment",
2120+
"--path",
2121+
"python/test_file.py",
2122+
"--line",
2123+
"20",
2124+
"--side",
2125+
"RIGHT",
2126+
"--body-file",
2127+
"-",
2128+
"--pr",
2129+
"77928",
2130+
"--repo",
2131+
"PaddlePaddle/Paddle",
2132+
]
2133+
)
2134+
2135+
assert code == 0
2136+
out = capsys.readouterr().out
2137+
assert "status: commented" in out
2138+
graphql_calls = [call for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
2139+
review_call = next(call for call in graphql_calls if "addPullRequestReviewThread" in _extract_form(call, "query"))
2140+
assert _extract_field(review_call, "body") == "stdin review comment\nwith second line\n"
2141+
2142+
2143+
def test_pr_review_suggest_supports_body_file(
2144+
monkeypatch: pytest.MonkeyPatch,
2145+
tmp_path: Path,
2146+
capsys: pytest.CaptureFixture[str],
2147+
) -> None:
2148+
responder = GhResponder()
2149+
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
2150+
body_file = tmp_path / "suggestion.md"
2151+
body_file.write_text("nits from file\n", encoding="utf-8")
2152+
2153+
code = cli.run(
2154+
[
2155+
"pr",
2156+
"review-suggest",
2157+
"--path",
2158+
"python/test_file.py",
2159+
"--line",
2160+
"20",
2161+
"--side",
2162+
"RIGHT",
2163+
"--body-file",
2164+
str(body_file),
2165+
"--suggestion",
2166+
"new_api_call()",
2167+
"--pr",
2168+
"77928",
2169+
"--repo",
2170+
"PaddlePaddle/Paddle",
2171+
]
2172+
)
2173+
2174+
assert code == 0
2175+
out = capsys.readouterr().out
2176+
assert "status: suggested" in out
2177+
graphql_calls = [call for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
2178+
review_call = next(call for call in graphql_calls if "addPullRequestReviewThread" in _extract_form(call, "query"))
2179+
assert _extract_field(review_call, "body") == "nits from file\n\n```suggestion\nnew_api_call()\n```"
2180+
2181+
2182+
def test_pr_thread_reply_rejects_body_and_body_file_together(
2183+
tmp_path: Path,
2184+
capsys: pytest.CaptureFixture[str],
2185+
) -> None:
2186+
body_file = tmp_path / "reply.md"
2187+
body_file.write_text("reply from file\n", encoding="utf-8")
2188+
2189+
try:
2190+
cli.run(
2191+
[
2192+
"pr",
2193+
"thread-reply",
2194+
"PRRT_mock_1",
2195+
"--body",
2196+
"inline reply",
2197+
"--body-file",
2198+
str(body_file),
2199+
]
2200+
)
2201+
except SystemExit as exc:
2202+
assert exc.code == 2
2203+
else: # pragma: no cover - defensive assertion
2204+
raise AssertionError("expected argparse to reject --body with --body-file")
2205+
2206+
err = capsys.readouterr().err
2207+
assert "argument -F/--body-file: not allowed with argument --body" in err
2208+
2209+
20742210
def _extract_form(cmd: list[str], key: str) -> str:
20752211
for idx, token in enumerate(cmd):
20762212
if token == "-f" and idx + 1 < len(cmd):

0 commit comments

Comments
 (0)