111111class 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+
290360def _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
573655def _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
0 commit comments