@@ -665,6 +665,13 @@ def cmd_pr_review_start(args: Any) -> int:
665665def cmd_pr_review_comment (args : Any ) -> int :
666666 client = GitHubClient ()
667667 meta = _resolve_pr_meta (client = client , args = args )
668+ _validate_review_thread_target (
669+ client = client ,
670+ args = args ,
671+ path = str (args .path ),
672+ line = int (args .line ),
673+ side = str (args .side ),
674+ )
668675 thread_id , comment_id = client .add_pull_request_review_thread_comment (
669676 ref = meta .ref ,
670677 path = str (args .path ),
@@ -682,6 +689,13 @@ def cmd_pr_review_comment(args: Any) -> int:
682689def cmd_pr_review_suggest (args : Any ) -> int :
683690 client = GitHubClient ()
684691 meta = _resolve_pr_meta (client = client , args = args )
692+ _validate_review_thread_target (
693+ client = client ,
694+ args = args ,
695+ path = str (args .path ),
696+ line = int (args .line ),
697+ side = str (args .side ),
698+ )
685699 suggestion = str (args .suggestion ).rstrip ("\n " )
686700 full_body = f"{ str (args .body ).rstrip ()} \n \n ```suggestion\n { suggestion } \n ```"
687701 thread_id , comment_id = client .add_pull_request_review_thread_comment (
@@ -845,49 +859,89 @@ def _resolve_pr_meta(*, client: GitHubClient, args: Any) -> PullRequestMeta:
845859
846860
847861class _DiffHunk :
848- def __init__ (self , path : str , header : str , anchor_line : int , lines : list [str ]) -> None :
862+ def __init__ (
863+ self ,
864+ path : str ,
865+ header : str ,
866+ anchor_line : int ,
867+ lines : list [str ],
868+ * ,
869+ left_commentable_lines : set [int ],
870+ right_commentable_lines : set [int ],
871+ match_paths : set [str ],
872+ ) -> None :
849873 self .path = path
850874 self .header = header
851875 self .anchor_line = anchor_line
852876 self .lines = lines
877+ self .left_commentable_lines = left_commentable_lines
878+ self .right_commentable_lines = right_commentable_lines
879+ self .match_paths = match_paths
853880
854881
855882_HUNK_HEADER_RE = re .compile (r"^@@ -(?P<old>\d+)(?:,\d+)? \+(?P<new>\d+)(?:,\d+)? @@" )
856883
857884
858885def _extract_diff_hunks (diff : str ) -> list [_DiffHunk ]:
859886 hunks : list [_DiffHunk ] = []
860- current_path = ""
887+ current_old_path = ""
888+ current_new_path = ""
861889 current_hunk_header = ""
862890 current_hunk_lines : list [str ] = []
891+ current_old_line = 0
863892 current_new_line = 0
864893 current_anchor = 0
894+ current_fallback_anchor = 0
895+ current_left_commentable_lines : set [int ] = set ()
896+ current_right_commentable_lines : set [int ] = set ()
897+
898+ def resolve_hunk_path () -> tuple [str , set [str ]]:
899+ match_paths = {path for path in (current_old_path , current_new_path ) if path }
900+ if current_new_path :
901+ return current_new_path , match_paths
902+ if current_old_path :
903+ return current_old_path , match_paths
904+ return "" , match_paths
865905
866906 def flush () -> None :
867- nonlocal current_hunk_header , current_hunk_lines , current_anchor
868- if current_path and current_hunk_header and current_hunk_lines :
907+ nonlocal current_hunk_header , current_hunk_lines , current_anchor , current_fallback_anchor
908+ nonlocal current_left_commentable_lines , current_right_commentable_lines
909+ path , match_paths = resolve_hunk_path ()
910+ if path and current_hunk_header and current_hunk_lines :
911+ anchor_line = (
912+ current_anchor if current_anchor > 0 else current_fallback_anchor if current_fallback_anchor > 0 else 1
913+ )
869914 hunks .append (
870915 _DiffHunk (
871- path = current_path ,
916+ path = path ,
872917 header = current_hunk_header ,
873- anchor_line = current_anchor if current_anchor > 0 else 1 ,
918+ anchor_line = anchor_line ,
874919 lines = current_hunk_lines .copy (),
920+ left_commentable_lines = current_left_commentable_lines .copy (),
921+ right_commentable_lines = current_right_commentable_lines .copy (),
922+ match_paths = match_paths ,
875923 )
876924 )
877925 current_hunk_header = ""
878926 current_hunk_lines = []
879927 current_anchor = 0
928+ current_fallback_anchor = 0
929+ current_left_commentable_lines = set ()
930+ current_right_commentable_lines = set ()
880931
881932 for raw in diff .splitlines ():
882933 if raw .startswith ("diff --git " ):
883934 flush ()
935+ current_old_path = ""
936+ current_new_path = ""
884937 continue
885- if raw .startswith ("--- a/ " ):
938+ if raw .startswith ("--- " ):
886939 flush ()
940+ current_old_path = "" if raw == "--- /dev/null" else raw [len ("--- a/" ) :]
887941 continue
888- if raw .startswith ("+++ b/ " ):
942+ if raw .startswith ("+++ " ):
889943 flush ()
890- current_path = raw [len ("+++ b/" ) :]
944+ current_new_path = "" if raw == "+++ /dev/null" else raw [len ("+++ b/" ) :]
891945 continue
892946
893947 if raw .startswith ("@@ " ):
@@ -896,11 +950,11 @@ def flush() -> None:
896950 current_hunk_lines = [raw ]
897951 match = _HUNK_HEADER_RE .match (raw )
898952 if match is None :
953+ current_old_line = 1
899954 current_new_line = 1
900- current_anchor = 1
901955 else :
956+ current_old_line = int (match .group ("old" ))
902957 current_new_line = int (match .group ("new" ))
903- current_anchor = current_new_line
904958 continue
905959
906960 if not current_hunk_header :
@@ -910,16 +964,57 @@ def flush() -> None:
910964 if raw .startswith ("+" ):
911965 if current_anchor <= 0 :
912966 current_anchor = current_new_line
967+ if current_fallback_anchor <= 0 :
968+ current_fallback_anchor = current_new_line
969+ current_right_commentable_lines .add (current_new_line )
913970 current_new_line += 1
914971 elif raw .startswith (" " ):
972+ if current_fallback_anchor <= 0 :
973+ current_fallback_anchor = current_new_line
974+ current_left_commentable_lines .add (current_old_line )
975+ current_right_commentable_lines .add (current_new_line )
976+ current_old_line += 1
915977 current_new_line += 1
916978 elif raw .startswith ("-" ):
917- continue
979+ current_left_commentable_lines .add (current_old_line )
980+ current_old_line += 1
918981
919982 flush ()
920983 return hunks
921984
922985
986+ def _validate_review_thread_target (* , client : GitHubClient , args : Any , path : str , line : int , side : str ) -> None :
987+ diff = client .fetch_pr_diff (selector = getattr (args , "pr" , None ), repo = getattr (args , "repo" , None ))
988+ hunks = _extract_diff_hunks (diff )
989+ path_hunks = [hunk for hunk in hunks if path in hunk .match_paths ]
990+ if not path_hunks :
991+ raise RuntimeError (f"path is not part of the PR diff: { path } " )
992+
993+ commentable_lines = sorted (
994+ {
995+ candidate
996+ for hunk in path_hunks
997+ for candidate in (hunk .right_commentable_lines if side == "RIGHT" else hunk .left_commentable_lines )
998+ }
999+ )
1000+ if line in commentable_lines :
1001+ return
1002+
1003+ if not commentable_lines :
1004+ raise RuntimeError (
1005+ f"line { line } on { side } is not a commentable diff line for { path } . "
1006+ f"The current diff has no commentable lines on { side } for that file."
1007+ )
1008+
1009+ preview = ", " .join (str (candidate ) for candidate in commentable_lines [:8 ])
1010+ if len (commentable_lines ) > 8 :
1011+ preview += ", ..."
1012+ raise RuntimeError (
1013+ f"line { line } on { side } is not a commentable diff line for { path } . "
1014+ f"Try a line from the PR diff for that side instead (e.g. { preview } )."
1015+ )
1016+
1017+
9231018def parse_event_indexes (raw_indexes : list [str ]) -> list [int ]:
9241019 values : set [int ] = set ()
9251020 for raw in raw_indexes :
0 commit comments