Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ gh-llm issue comment-edit IC_xxx --body '<new_body>' --issue 77924 --repo Paddle

# Reply / resolve / unresolve review thread
gh-llm pr thread-reply PRRT_xxx --body '<reply>' --pr 77900 --repo PaddlePaddle/Paddle
gh-llm pr thread-reply PRRT_xxx --body-file reply.md --pr 77900 --repo PaddlePaddle/Paddle
cat reply.md | gh-llm pr thread-reply PRRT_xxx --body-file - --pr 77900 --repo PaddlePaddle/Paddle
gh-llm pr thread-resolve PRRT_xxx --pr 77900 --repo PaddlePaddle/Paddle
gh-llm pr thread-unresolve PRRT_xxx --pr 77900 --repo PaddlePaddle/Paddle
```
Expand Down Expand Up @@ -188,6 +190,13 @@ gh-llm pr review-comment \
--side RIGHT \
--body 'Please add a regression test for duplicate keyword arguments.' \
--pr 77938 --repo PaddlePaddle/Paddle

gh-llm pr review-comment \
--path 'paddle/phi/api/include/compat/torch/library.h' \
--line 106 \
--side RIGHT \
--body-file review-comment.md \
--pr 77938 --repo PaddlePaddle/Paddle
```

### 3) Add inline suggestion
Expand All @@ -200,6 +209,14 @@ gh-llm pr review-suggest \
--body 'Suggested update' \
--suggestion 'replacement_code_here' \
--pr 77938 --repo PaddlePaddle/Paddle

gh-llm pr review-suggest \
--path 'path/to/file' \
--line 123 \
--side RIGHT \
--body-file suggestion-reason.md \
--suggestion 'replacement_code_here' \
--pr 77938 --repo PaddlePaddle/Paddle
```

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

`thread-reply`, `review-comment`, `review-suggest`, and `review-submit` all support `--body-file -` to read multi-line text from standard input.

Submit behavior:

- If you already have a pending review on this PR, `review-submit` submits that pending review.
Expand Down
43 changes: 35 additions & 8 deletions src/gh_llm/commands/pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ def register_pr_parser(subparsers: Any) -> None:

thread_reply_parser = pr_subparsers.add_parser("thread-reply", help="reply to a pull request review thread")
thread_reply_parser.add_argument("thread_id", help="review thread id, e.g. PRRT_xxx")
thread_reply_parser.add_argument("--body", required=True, help="reply body")
thread_reply_body_group = thread_reply_parser.add_mutually_exclusive_group(required=True)
thread_reply_body_group.add_argument("--body", help="reply body")
thread_reply_body_group.add_argument(
"-F",
"--body-file",
help="read reply body from file (use `-` to read from standard input)",
)
thread_reply_parser.add_argument("--pr", help="PR number/url/branch")
thread_reply_parser.add_argument("--repo", help="repository in OWNER/REPO format")
thread_reply_parser.set_defaults(handler=cmd_pr_thread_reply)
Expand Down Expand Up @@ -279,7 +285,13 @@ def register_pr_parser(subparsers: Any) -> None:
help="starting diff side for a multi-line range (defaults to --side)",
)
review_comment_parser.add_argument("--head", help="expected PR head sha for stale-snapshot protection")
review_comment_parser.add_argument("--body", required=True, help="review comment body")
review_comment_body_group = review_comment_parser.add_mutually_exclusive_group(required=True)
review_comment_body_group.add_argument("--body", help="review comment body")
review_comment_body_group.add_argument(
"-F",
"--body-file",
help="read review comment body from file (use `-` to read from standard input)",
)
review_comment_parser.add_argument("--pr", help="PR number/url/branch")
review_comment_parser.add_argument("--repo", help="repository in OWNER/REPO format")
review_comment_parser.set_defaults(handler=cmd_pr_review_comment)
Expand All @@ -290,11 +302,17 @@ def register_pr_parser(subparsers: Any) -> None:
review_suggest_parser.add_argument("--path", required=True, help="file path in pull request")
review_suggest_parser.add_argument("--line", required=True, type=int, help="line number on selected side")
review_suggest_parser.add_argument("--side", choices=["RIGHT", "LEFT"], default="RIGHT", help="diff side")
review_suggest_parser.add_argument(
review_suggest_body_group = review_suggest_parser.add_mutually_exclusive_group()
review_suggest_body_group.add_argument(
"--body",
default="Suggested change",
help="review comment body before suggestion block",
)
review_suggest_body_group.add_argument(
"-F",
"--body-file",
help="read review comment body from file (use `-` to read from standard input)",
)
review_suggest_parser.add_argument(
"--suggestion",
required=True,
Expand Down Expand Up @@ -332,11 +350,18 @@ def _read_body_file(path: str) -> str:
return Path(path).read_text(encoding="utf-8")


def _resolve_review_submit_body(args: Any) -> str:
def _resolve_body_argument(args: Any, *, default: str = "") -> str:
body_file = getattr(args, "body_file", None)
if body_file:
return _read_body_file(str(body_file))
return str(getattr(args, "body", ""))
body = getattr(args, "body", None)
if body is None:
return default
return str(body)


def _resolve_review_submit_body(args: Any) -> str:
return _resolve_body_argument(args)


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

comment_id = client.reply_review_thread(thread_id=str(args.thread_id), body=str(args.body))
body = _resolve_body_argument(args)
comment_id = client.reply_review_thread(thread_id=str(args.thread_id), body=body)
print(f"thread: {args.thread_id}")
if comment_id:
print(f"reply_comment_id: {comment_id}")
Expand Down Expand Up @@ -1046,7 +1072,7 @@ def cmd_pr_review_comment(args: Any) -> int:
side=str(args.side),
start_line=start_line,
start_side=start_side,
body=str(args.body),
body=_resolve_body_argument(args),
)
print(f"thread: {thread_id}")
if comment_id:
Expand All @@ -1071,7 +1097,8 @@ def cmd_pr_review_suggest(args: Any) -> int:
start_side=start_side,
)
suggestion = str(args.suggestion).rstrip("\n")
full_body = f"{str(args.body).rstrip()}\n\n```suggestion\n{suggestion}\n```"
body = _resolve_body_argument(args, default="Suggested change")
full_body = f"{body.rstrip()}\n\n```suggestion\n{suggestion}\n```"
thread_id, comment_id = client.add_pull_request_review_thread_comment(
ref=meta.ref,
path=str(args.path),
Expand Down
136 changes: 136 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2071,6 +2071,142 @@ def run_with_contents_failure(
assert not output_path.exists()


def test_pr_thread_reply_supports_body_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
body_file = tmp_path / "reply.md"
body_file.write_text("> quoted context\n\nreply from file\n", encoding="utf-8")

code = cli.run(
[
"pr",
"thread-reply",
"PRRT_mock_1",
"--body-file",
str(body_file),
"--pr",
"77928",
"--repo",
"PaddlePaddle/Paddle",
]
)

assert code == 0
out = capsys.readouterr().out
assert "status: replied" in out
graphql_calls = [call for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
reply_call = next(
call for call in graphql_calls if "addPullRequestReviewThreadReply" in _extract_form(call, "query")
)
assert _extract_field(reply_call, "body") == "> quoted context\n\nreply from file\n"


def test_pr_review_comment_supports_body_file_stdin(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
monkeypatch.setattr(sys, "stdin", _FakeStdin("stdin review comment\nwith second line\n"))

code = cli.run(
[
"pr",
"review-comment",
"--path",
"python/test_file.py",
"--line",
"20",
"--side",
"RIGHT",
"--body-file",
"-",
"--pr",
"77928",
"--repo",
"PaddlePaddle/Paddle",
]
)

assert code == 0
out = capsys.readouterr().out
assert "status: commented" in out
graphql_calls = [call for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
review_call = next(call for call in graphql_calls if "addPullRequestReviewThread" in _extract_form(call, "query"))
assert _extract_field(review_call, "body") == "stdin review comment\nwith second line\n"


def test_pr_review_suggest_supports_body_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
body_file = tmp_path / "suggestion.md"
body_file.write_text("nits from file\n", encoding="utf-8")

code = cli.run(
[
"pr",
"review-suggest",
"--path",
"python/test_file.py",
"--line",
"20",
"--side",
"RIGHT",
"--body-file",
str(body_file),
"--suggestion",
"new_api_call()",
"--pr",
"77928",
"--repo",
"PaddlePaddle/Paddle",
]
)

assert code == 0
out = capsys.readouterr().out
assert "status: suggested" in out
graphql_calls = [call for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
review_call = next(call for call in graphql_calls if "addPullRequestReviewThread" in _extract_form(call, "query"))
assert _extract_field(review_call, "body") == "nits from file\n\n```suggestion\nnew_api_call()\n```"


def test_pr_thread_reply_rejects_body_and_body_file_together(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
body_file = tmp_path / "reply.md"
body_file.write_text("reply from file\n", encoding="utf-8")

try:
cli.run(
[
"pr",
"thread-reply",
"PRRT_mock_1",
"--body",
"inline reply",
"--body-file",
str(body_file),
]
)
except SystemExit as exc:
assert exc.code == 2
else: # pragma: no cover - defensive assertion
raise AssertionError("expected argparse to reject --body with --body-file")

err = capsys.readouterr().err
assert "argument -F/--body-file: not allowed with argument --body" in err

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

This assertion depends on argparse’s exact error string, which can change between Python/argparse versions (or even reorder option names). To avoid brittle failures, assert more loosely (e.g., check for not allowed with argument and the presence of both --body and --body-file), rather than the full formatted message.

Suggested change
assert "argument -F/--body-file: not allowed with argument --body" in err
assert "not allowed with argument" in err
assert "--body" in err
assert "--body-file" in err

Copilot uses AI. Check for mistakes.


def _extract_form(cmd: list[str], key: str) -> str:
for idx, token in enumerate(cmd):
if token == "-f" and idx + 1 < len(cmd):
Expand Down
Loading