Skip to content

Commit ececc14

Browse files
committed
✨ feat: Add functionality to edit comments on pull requests and enhance rendering of editable comments
1 parent f7d3b0e commit ececc14

5 files changed

Lines changed: 190 additions & 4 deletions

File tree

src/gh_llm/commands/pr.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ def register_pr_parser(subparsers: Any) -> None:
8585
thread_unresolve_parser.add_argument("--repo", help="repository in OWNER/REPO format")
8686
thread_unresolve_parser.set_defaults(handler=cmd_pr_thread_unresolve)
8787

88+
comment_edit_parser = pr_subparsers.add_parser(
89+
"comment-edit", help="edit one issue/review comment by node id"
90+
)
91+
comment_edit_parser.add_argument("comment_id", help="comment id, e.g. IC_xxx or PRRC_xxx")
92+
comment_edit_parser.add_argument("--body", required=True, help="new comment body")
93+
comment_edit_parser.add_argument("--pr", help="PR number/url/branch")
94+
comment_edit_parser.add_argument("--repo", help="repository in OWNER/REPO format")
95+
comment_edit_parser.set_defaults(handler=cmd_pr_comment_edit)
96+
8897

8998
def cmd_pr_view(args: Any) -> int:
9099
page_size = int(args.page_size)
@@ -255,6 +264,18 @@ def cmd_pr_thread_unresolve(args: Any) -> int:
255264
return 0
256265

257266

267+
def cmd_pr_comment_edit(args: Any) -> int:
268+
client = GitHubClient()
269+
if args.repo is not None and args.pr is None:
270+
raise RuntimeError("`--pr` is required when `--repo` is provided")
271+
if args.pr is not None:
272+
client.resolve_pull_request(selector=args.pr, repo=args.repo)
273+
updated_comment_id = client.edit_comment(comment_id=str(args.comment_id), body=str(args.body))
274+
print(f"comment: {updated_comment_id}")
275+
print("status: edited")
276+
return 0
277+
278+
258279
def _resolve_context_and_meta(
259280
*, client: GitHubClient, pager: TimelinePager, args: Any
260281
) -> tuple[TimelineContext, PullRequestMeta]:

src/gh_llm/github_api.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
class GitHubClient:
112112
def __init__(self) -> None:
113113
self._review_threads_cache: dict[tuple[str, str, int], dict[str, list[dict[str, object]]]] = {}
114+
self._viewer_login: str | None = None
114115

115116
def resolve_pull_request(self, selector: str | None, repo: str | None) -> PullRequestMeta:
116117
fields = ["number", "title", "url", "author", "state", "isDraft", "body", "updatedAt"]
@@ -133,6 +134,8 @@ def resolve_pull_request(self, selector: str | None, repo: str | None) -> PullRe
133134

134135
owner, name = _parse_owner_repo(url)
135136
ref = PullRequestRef(owner=owner, name=name, number=number)
137+
if self._viewer_login is None:
138+
self._viewer_login = self._get_viewer_login()
136139
return PullRequestMeta(
137140
ref=ref,
138141
title=title,
@@ -163,6 +166,7 @@ def fetch_timeline_forward(
163166
ref=ref,
164167
threads_by_review=threads_by_review,
165168
show_resolved_details=show_resolved_details,
169+
viewer_login=self._viewer_login or "",
166170
)
167171

168172
def fetch_timeline_backward(
@@ -184,6 +188,7 @@ def fetch_timeline_backward(
184188
ref=ref,
185189
threads_by_review=threads_by_review,
186190
show_resolved_details=show_resolved_details,
191+
viewer_login=self._viewer_login or "",
187192
)
188193

189194
def _get_review_threads_by_review(self, ref: PullRequestRef) -> dict[str, list[dict[str, object]]]:
@@ -287,6 +292,71 @@ def unresolve_review_thread(self, thread_id: str) -> bool:
287292
thread_obj = _as_dict(unresolved_obj.get("thread"), context="unresolved thread")
288293
return bool(thread_obj.get("isResolved"))
289294

295+
def edit_comment(self, comment_id: str, body: str) -> str:
296+
if comment_id.startswith("PRRC_"):
297+
updated_id = self._try_update_pull_request_review_comment(comment_id=comment_id, body=body)
298+
if updated_id:
299+
return updated_id
300+
updated_id = self._try_update_issue_comment(comment_id=comment_id, body=body)
301+
if updated_id:
302+
return updated_id
303+
raise RuntimeError("failed to edit review comment")
304+
305+
updated_id = self._try_update_issue_comment(comment_id=comment_id, body=body)
306+
if updated_id:
307+
return updated_id
308+
updated_id = self._try_update_pull_request_review_comment(comment_id=comment_id, body=body)
309+
if updated_id:
310+
return updated_id
311+
raise RuntimeError("failed to edit comment")
312+
313+
def _try_update_issue_comment(self, *, comment_id: str, body: str) -> str | None:
314+
query = """
315+
mutation($id:ID!,$body:String!){
316+
updateIssueComment(input:{id:$id,body:$body}){
317+
issueComment{id}
318+
}
319+
}
320+
""".strip()
321+
payload = _run_graphql_payload(query, {"id": comment_id, "body": body})
322+
if _has_graphql_errors(payload):
323+
return None
324+
data_obj = _as_dict(payload.get("data"), context="graphql data")
325+
updated_obj = _as_dict_optional(data_obj.get("updateIssueComment"))
326+
if updated_obj is None:
327+
return None
328+
comment_obj = _as_dict_optional(updated_obj.get("issueComment"))
329+
if comment_obj is None:
330+
return None
331+
updated_id = _as_optional_str(comment_obj.get("id"))
332+
return updated_id or None
333+
334+
def _try_update_pull_request_review_comment(self, *, comment_id: str, body: str) -> str | None:
335+
query = """
336+
mutation($id:ID!,$body:String!){
337+
updatePullRequestReviewComment(input:{pullRequestReviewCommentId:$id,body:$body}){
338+
pullRequestReviewComment{id}
339+
}
340+
}
341+
""".strip()
342+
payload = _run_graphql_payload(query, {"id": comment_id, "body": body})
343+
if _has_graphql_errors(payload):
344+
return None
345+
data_obj = _as_dict(payload.get("data"), context="graphql data")
346+
updated_obj = _as_dict_optional(data_obj.get("updatePullRequestReviewComment"))
347+
if updated_obj is None:
348+
return None
349+
comment_obj = _as_dict_optional(updated_obj.get("pullRequestReviewComment"))
350+
if comment_obj is None:
351+
return None
352+
updated_id = _as_optional_str(comment_obj.get("id"))
353+
return updated_id or None
354+
355+
def _get_viewer_login(self) -> str:
356+
payload = _run_command_json(["gh", "api", "user"])
357+
login = _as_optional_str(payload.get("login"))
358+
return login or ""
359+
290360
def _run_graphql_connection(query: str, variables: dict[str, str | int]) -> dict[str, object]:
291361
payload = _run_graphql_payload(query, variables)
292362
data_obj = _as_dict(payload.get("data"), context="graphql data")
@@ -319,6 +389,7 @@ def _parse_timeline_page(
319389
ref: PullRequestRef,
320390
threads_by_review: dict[str, list[dict[str, object]]],
321391
show_resolved_details: bool,
392+
viewer_login: str,
322393
) -> TimelinePage:
323394
total_count = _as_int_default(connection.get("totalCount"), default=0)
324395
page_info_obj = _as_dict(connection.get("pageInfo"), context="pageInfo")
@@ -336,6 +407,7 @@ def _parse_timeline_page(
336407
ref=ref,
337408
threads_for_review=threads_by_review,
338409
show_resolved_details=show_resolved_details,
410+
viewer_login=viewer_login,
339411
)
340412
if parsed is not None:
341413
items.append(parsed)
@@ -350,6 +422,7 @@ def _parse_node(
350422
ref: PullRequestRef,
351423
threads_for_review: dict[str, list[dict[str, object]]],
352424
show_resolved_details: bool,
425+
viewer_login: str,
353426
) -> TimelineEvent | None:
354427
typename = str(node.get("__typename") or "")
355428
if typename == "IssueComment":
@@ -363,6 +436,9 @@ def _parse_node(
363436
source_id=_as_optional_str(node.get("id")) or "comment",
364437
full_text=body,
365438
is_truncated=is_truncated,
439+
editable_comment_id=(
440+
_as_optional_str(node.get("id")) if _get_login(node.get("author")) == viewer_login else None
441+
),
366442
)
367443

368444
if typename == "PullRequestReview":
@@ -374,6 +450,7 @@ def _parse_node(
374450
state=state,
375451
threads_for_review=threads_for_review.get(review_id, []),
376452
show_resolved_details=show_resolved_details,
453+
viewer_login=viewer_login,
377454
)
378455
summary, is_truncated = _clip_text(full_review, f"review state: {state.lower()}")
379456
return TimelineEvent(
@@ -490,6 +567,7 @@ def _build_review_text(
490567
*,
491568
threads_for_review: list[dict[str, object]],
492569
show_resolved_details: bool,
570+
viewer_login: str,
493571
) -> tuple[str, int]:
494572
body = (_as_optional_str(node.get("body")) or "").strip()
495573
total_count = sum(len(_as_list(_as_dict(thread, context="thread").get("comments"))) for thread in threads_for_review)
@@ -513,6 +591,7 @@ def _build_review_text(
513591
thread_index=rendered_thread_index,
514592
comments=comment_nodes,
515593
ref=ref,
594+
viewer_login=viewer_login,
516595
)
517596
)
518597
rendered_comments += len(comment_nodes)
@@ -543,6 +622,7 @@ def _render_review_thread_block(
543622
thread_index: int,
544623
comments: list[object],
545624
ref: PullRequestRef,
625+
viewer_login: str,
546626
) -> list[str]:
547627
lines = [f"- Thread[{thread_index}] {thread_id}"]
548628
for comment_index, raw_comment in enumerate(comments, start=1):
@@ -552,6 +632,8 @@ def _render_review_thread_block(
552632
comment=comment,
553633
index=comment_index,
554634
include_diff_hunk=(comment_index == 1),
635+
ref=ref,
636+
viewer_login=viewer_login,
555637
)
556638
)
557639
lines.append(f" 🆔 thread_id: {thread_id}")
@@ -571,7 +653,12 @@ def _render_review_thread_block(
571653

572654

573655
def _render_review_comment_block(
574-
comment: dict[str, object], index: int, *, include_diff_hunk: bool = True
656+
comment: dict[str, object],
657+
index: int,
658+
*,
659+
include_diff_hunk: bool = True,
660+
ref: PullRequestRef,
661+
viewer_login: str,
575662
) -> list[str]:
576663
path = _as_optional_str(comment.get("path")) or "(unknown path)"
577664
line = _as_line_ref(comment)
@@ -594,6 +681,13 @@ def _render_review_comment_block(
594681
if suggestion_diff:
595682
lines.append(" Suggested Change:")
596683
lines.extend(_indented_fenced_block("diff", suggestion_diff, indent=" "))
684+
comment_id = _as_optional_str(comment.get("id")) or ""
685+
if comment_id and author == viewer_login:
686+
lines.append(f" 🆔 comment_id: {comment_id}")
687+
lines.append(" ⌨ comment_body: '<comment_body>'")
688+
lines.append(
689+
f" ⏎ Edit comment via gh-llm: `gh-llm pr comment-edit {comment_id} --body '<comment_body>' --pr {ref.number} --repo {ref.owner}/{ref.name}`"
690+
)
597691

598692
if not body and not diff_hunk:
599693
lines.append(" (empty review comment)")
@@ -706,3 +800,7 @@ def _as_int_default(value: object, *, default: int) -> int:
706800
except ValueError:
707801
return default
708802
return default
803+
804+
805+
def _has_graphql_errors(payload: dict[str, object]) -> bool:
806+
return len(_as_list(payload.get("errors"))) > 0

src/gh_llm/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class TimelineEvent:
4444
full_text: str | None = None
4545
is_truncated: bool = False
4646
resolved_hidden_count: int = 0
47+
editable_comment_id: str | None = None
4748

4849

4950
@dataclass(frozen=True)

src/gh_llm/render.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ def _render_item(index: int, event: TimelineEvent, context: TimelineContext) ->
106106
if event.kind == "comment":
107107
lines.append(" Comment:")
108108
lines.extend(_indented_tag_block("comment", event.summary, indent=" "))
109+
if event.editable_comment_id:
110+
lines.append(f" 🆔 comment_id: {event.editable_comment_id}")
111+
lines.append(" ⌨ comment_body: '<comment_body>'")
112+
lines.append(
113+
f" ⏎ Edit comment via gh-llm: `gh-llm pr comment-edit {event.editable_comment_id} --body '<comment_body>' --pr {context.number} --repo {context.owner}/{context.name}`"
114+
)
109115
else:
110116
lines.extend(_indent_block(event.summary))
111117
if event.resolved_hidden_count > 0:

tests/test_cli.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def run(self, cmd: list[str], *, check: bool, capture_output: bool, text: bool)
4242
)
4343
)
4444

45+
if cmd[:3] == ["gh", "api", "user"]:
46+
return FakeCompletedProcess(json.dumps({"login": "ShigureNyako"}))
47+
4548
if cmd[:3] != ["gh", "api", "graphql"]:
4649
return FakeCompletedProcess("", returncode=1, stderr="unexpected command")
4750

@@ -67,6 +70,30 @@ def run(self, cmd: list[str], *, check: bool, capture_output: bool, text: bool)
6770
)
6871
)
6972

73+
if "updatePullRequestReviewComment" in query:
74+
return FakeCompletedProcess(
75+
json.dumps(
76+
{
77+
"data": {
78+
"updatePullRequestReviewComment": {
79+
"pullRequestReviewComment": {"id": "PRRC_self_1"}
80+
}
81+
}
82+
}
83+
)
84+
)
85+
86+
if "updateIssueComment" in query:
87+
return FakeCompletedProcess(
88+
json.dumps(
89+
{
90+
"data": {
91+
"updateIssueComment": {"issueComment": {"id": "c3"}}
92+
}
93+
}
94+
)
95+
)
96+
7097
if "unresolveReviewThread" in query:
7198
return FakeCompletedProcess(
7299
json.dumps(
@@ -144,6 +171,7 @@ def test_view_and_expand_use_real_cursor_pagination(
144171
assert "gh pr edit 77928 --repo PaddlePaddle/Paddle --add-reviewer '<reviewer1>,<reviewer2>'" in out
145172
assert "gh pr edit 77928 --repo PaddlePaddle/Paddle --add-assignee '<assignee1>,<assignee2>'" in out
146173
assert "link:" not in out
174+
assert "Edit comment via gh-llm: `gh-llm pr comment-edit c3 --body '<comment_body>' --pr 77928 --repo PaddlePaddle/Paddle`" in out
147175

148176
pre_expand_calls = len(responder.calls)
149177
code = cli.run(
@@ -162,7 +190,7 @@ def test_view_and_expand_use_real_cursor_pagination(
162190
assert code == 0
163191
out = capsys.readouterr().out
164192
assert "## Timeline Page 3/4" in out
165-
assert "Review comments (1/2 shown):" in out
193+
assert "Review comments (2/3 shown):" in out
166194
assert "Thread[1] PRRT_mock_1" in out
167195
assert "[1] python/test_file.py:L21 by @reviewer" in out
168196
assert "[2] python/test_file.py:L22 by @reviewer" not in out
@@ -173,6 +201,7 @@ def test_view_and_expand_use_real_cursor_pagination(
173201
assert "Reply via gh-llm:" in out
174202
assert "Resolve via gh-llm:" in out
175203
assert "Unresolve via gh-llm:" not in out
204+
assert "Edit comment via gh-llm: `gh-llm pr comment-edit PRRC_self_1 --body '<comment_body>' --pr 77928 --repo PaddlePaddle/Paddle`" in out
176205
assert "Reply via gh: `gh api graphql" not in out
177206
assert "Resolve via gh: `gh api graphql" not in out
178207

@@ -222,6 +251,24 @@ def test_view_and_expand_use_real_cursor_pagination(
222251
assert "thread: PRRT_mock_1" in out
223252
assert "status: resolved" in out
224253

254+
code = cli.run(
255+
[
256+
"pr",
257+
"comment-edit",
258+
"c3",
259+
"--body",
260+
"updated body",
261+
"--pr",
262+
"77928",
263+
"--repo",
264+
"PaddlePaddle/Paddle",
265+
]
266+
)
267+
assert code == 0
268+
out = capsys.readouterr().out
269+
assert "comment: c3" in out
270+
assert "status: edited" in out
271+
225272
code = cli.run(
226273
[
227274
"pr",
@@ -393,6 +440,19 @@ def _review_threads_payload(after: str | None) -> dict[str, Any]:
393440
"createdAt": "2026-02-14T14:50:01Z",
394441
"author": {"login": "reviewer"},
395442
"pullRequestReview": {"id": "PRR_mock"},
443+
},
444+
{
445+
"id": "PRRC_self_1",
446+
"path": "python/test_file.py",
447+
"body": "self reply",
448+
"line": 23,
449+
"originalLine": 23,
450+
"startLine": None,
451+
"originalStartLine": None,
452+
"diffHunk": "@@ -23,1 +23,1 @@\n-old\n+new",
453+
"createdAt": "2026-02-14T14:50:03Z",
454+
"author": {"login": "ShigureNyako"},
455+
"pullRequestReview": {"id": "PRR_mock"},
396456
}
397457
]
398458
},
@@ -461,8 +521,8 @@ def _events() -> list[dict[str, Any]]:
461521
"id": "c3",
462522
"url": "https://example.com/c3",
463523
"createdAt": "2026-02-14T15:11:00Z",
464-
"body": "tail event",
465-
"author": {"login": "tail"},
524+
"body": "self comment",
525+
"author": {"login": "ShigureNyako"},
466526
},
467527
]
468528

0 commit comments

Comments
 (0)