Skip to content

Commit 9df7bcc

Browse files
authored
🐛 fix: validate PR review thread targets (#6)
1 parent 849ed95 commit 9df7bcc

3 files changed

Lines changed: 333 additions & 13 deletions

File tree

src/gh_llm/commands/pr.py

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,13 @@ def cmd_pr_review_start(args: Any) -> int:
665665
def 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:
682689
def 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

847861
class _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

858885
def _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+
9231018
def parse_event_indexes(raw_indexes: list[str]) -> list[int]:
9241019
values: set[int] = set()
9251020
for raw in raw_indexes:

src/gh_llm/github_api.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1312,7 +1312,12 @@ def add_pull_request_review_thread_comment(
13121312
)
13131313
data_obj = _as_dict(payload.get("data"), context="graphql data")
13141314
added_obj = _as_dict(data_obj.get("addPullRequestReviewThread"), context="addPullRequestReviewThread")
1315-
thread_obj = _as_dict(added_obj.get("thread"), context="thread")
1315+
thread_obj = _as_dict_optional(added_obj.get("thread"))
1316+
if thread_obj is None:
1317+
raise RuntimeError(
1318+
"failed to create review thread: GitHub rejected the requested review location "
1319+
f"({path}:{line} {side}). The line may be outside the current PR diff or otherwise not commentable."
1320+
)
13161321
thread_id = _as_optional_str(thread_obj.get("id")) or ""
13171322
comments_obj = _as_dict(thread_obj.get("comments"), context="thread comments")
13181323
comment_nodes = _as_list(comments_obj.get("nodes"))

0 commit comments

Comments
 (0)