Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch GitHub PR review comments and output JSON for insert_code_review_comments.
|
||||
|
||||
Requires: gh CLI (authenticated), git.
|
||||
Must be run from within a git repository whose current branch has an open PR.
|
||||
|
||||
Prints JSON to stdout matching the insert_code_review_comments tool schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from trim_diff_hunk import trim_diff_hunk, line_in_hunk, last_reachable_line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_command(args, error_msg="Command failed"):
|
||||
"""Run a command and return stdout. Exits on failure."""
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
env={**os.environ, "GH_PAGER": ""},
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"{error_msg}: {result.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def run_gh_api(endpoint):
|
||||
"""Run ``gh api --paginate`` and return a list of JSON objects.
|
||||
|
||||
Handles the case where ``gh`` outputs multiple concatenated JSON arrays
|
||||
(one per page).
|
||||
"""
|
||||
text = run_command(
|
||||
["gh", "api", endpoint, "--paginate"],
|
||||
error_msg=f"gh api {endpoint} failed",
|
||||
).strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
items = []
|
||||
decoder = json.JSONDecoder()
|
||||
pos = 0
|
||||
try:
|
||||
while pos < len(text):
|
||||
while pos < len(text) and text[pos] in " \t\n\r":
|
||||
pos += 1
|
||||
if pos >= len(text):
|
||||
break
|
||||
obj, end = decoder.raw_decode(text, pos)
|
||||
items.extend(obj if isinstance(obj, list) else [obj])
|
||||
pos = end
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"Failed to parse API response: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment building
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _comment(cid, author, ts, body, url, location=None, reply_to=None):
|
||||
"""Build a dict matching the insert_code_review_comments comment schema."""
|
||||
c = {
|
||||
"comment_id": cid,
|
||||
"author": author,
|
||||
"last_modified_timestamp": ts,
|
||||
"comment_body": body,
|
||||
"html_url": url,
|
||||
}
|
||||
if reply_to:
|
||||
c["reply_metadata"] = {"parent_comment_id": reply_to}
|
||||
elif location:
|
||||
c["location_metadata"] = location
|
||||
return c
|
||||
|
||||
|
||||
def _resolve_line(hunk, line, original_line, side):
|
||||
"""Pick the first line number that is reachable in the hunk on *side*.
|
||||
|
||||
Tries *line* first (current diff position), then *original_line*
|
||||
(position when the comment was placed). If neither is reachable,
|
||||
falls back to the last reachable line in the hunk on *side*.
|
||||
|
||||
Returns the resolved line number, or ``None`` if nothing is reachable.
|
||||
"""
|
||||
if line and line_in_hunk(hunk, line, side):
|
||||
return line
|
||||
if original_line and line_in_hunk(hunk, original_line, side):
|
||||
return original_line
|
||||
return last_reachable_line(hunk, side)
|
||||
|
||||
|
||||
def _resolve_comment_line(comment, hunk):
|
||||
"""Resolve validated (end_line, start_line, side) for a diff comment.
|
||||
|
||||
Uses ``side`` from the GitHub API as the authoritative diff side.
|
||||
Returns ``(end_line, start_line | None, side)`` or ``None`` when the
|
||||
comment cannot be attached to any line in the hunk.
|
||||
"""
|
||||
side = comment.get("side") or "RIGHT"
|
||||
|
||||
end_line = _resolve_line(
|
||||
hunk,
|
||||
comment.get("line"),
|
||||
comment.get("original_line"),
|
||||
side,
|
||||
)
|
||||
if end_line is None:
|
||||
return None
|
||||
|
||||
raw_start = comment.get("start_line")
|
||||
raw_original_start = comment.get("original_start_line")
|
||||
if raw_start or raw_original_start:
|
||||
start_line = _resolve_line(hunk, raw_start, raw_original_start, side)
|
||||
if start_line is not None and start_line > end_line:
|
||||
start_line = None
|
||||
else:
|
||||
start_line = None
|
||||
|
||||
return (end_line, start_line, side)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
repo_root = run_command(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
"Not a git repository",
|
||||
).strip()
|
||||
|
||||
pr = json.loads(
|
||||
run_command(
|
||||
[
|
||||
"gh", "pr", "view",
|
||||
"--json", "number,headRepository,headRepositoryOwner,baseRefName",
|
||||
],
|
||||
"Failed to get PR info (is there an open PR on this branch?)",
|
||||
)
|
||||
)
|
||||
number = pr["number"]
|
||||
owner = pr["headRepositoryOwner"]["login"]
|
||||
repo = pr["headRepository"]["name"]
|
||||
base = pr["baseRefName"]
|
||||
|
||||
api = f"/repos/{owner}/{repo}"
|
||||
|
||||
issue_comments = run_gh_api(f"{api}/issues/{number}/comments")
|
||||
diff_comments = run_gh_api(f"{api}/pulls/{number}/comments")
|
||||
reviews = run_gh_api(f"{api}/pulls/{number}/reviews")
|
||||
|
||||
comments = []
|
||||
|
||||
# -- Issue comments (PR-level, no location or reply metadata) -----------
|
||||
for c in issue_comments:
|
||||
comments.append(
|
||||
_comment(
|
||||
str(c["id"]),
|
||||
c["user"]["login"] if c.get("user") else "[deleted]",
|
||||
c["updated_at"],
|
||||
c["body"],
|
||||
c["html_url"],
|
||||
)
|
||||
)
|
||||
|
||||
# -- Diff comments (line-level, with location or reply) -----------------
|
||||
for c in diff_comments:
|
||||
cid = str(c["id"])
|
||||
author = c["user"]["login"] if c.get("user") else "[deleted]"
|
||||
ts = c["updated_at"]
|
||||
body = c["body"]
|
||||
url = c["html_url"]
|
||||
|
||||
reply_to_id = c.get("in_reply_to_id")
|
||||
if reply_to_id:
|
||||
comments.append(
|
||||
_comment(cid, author, ts, body, url, reply_to=str(reply_to_id))
|
||||
)
|
||||
continue
|
||||
|
||||
hunk = c.get("diff_hunk", "")
|
||||
resolved = _resolve_comment_line(c, hunk)
|
||||
|
||||
loc = {"filepath": c["path"]}
|
||||
if resolved:
|
||||
end_line, start_line, side = resolved
|
||||
if hunk:
|
||||
loc["diff_hunk"] = trim_diff_hunk(
|
||||
hunk, end_line, side=side, start_line=start_line
|
||||
)
|
||||
loc["end_line"] = end_line
|
||||
if start_line:
|
||||
loc["start_line"] = start_line
|
||||
loc["side"] = side
|
||||
|
||||
comments.append(_comment(cid, author, ts, body, url, location=loc))
|
||||
|
||||
# -- Reviews (PR-level, no location) ------------------------------------
|
||||
for r in reviews:
|
||||
if not r.get("body"):
|
||||
continue
|
||||
comments.append(
|
||||
_comment(
|
||||
str(r["id"]),
|
||||
r["user"]["login"] if r.get("user") else "[deleted]",
|
||||
r.get("submitted_at", ""),
|
||||
r["body"],
|
||||
r["html_url"],
|
||||
)
|
||||
)
|
||||
|
||||
result = {
|
||||
"local_repository_path": repo_root,
|
||||
"base_branch": base,
|
||||
"comments": comments,
|
||||
}
|
||||
|
||||
json.dump(result, sys.stdout, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for _resolve_comment_line and _resolve_line (no network calls).
|
||||
|
||||
Uses synthetic GitHub API comment dicts and diff hunks to exercise the
|
||||
fallback chain: line → original_line → last reachable line → None.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from fetch_github_review_comments import _resolve_comment_line, _resolve_line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A small reusable hunk: new file lines 20-24, old file lines 10-14.
|
||||
# ---------------------------------------------------------------------------
|
||||
_CONTEXT_HUNK = "\n".join(
|
||||
["@@ -10,5 +20,5 @@"] + [f" line {10 + i}" for i in range(5)]
|
||||
)
|
||||
|
||||
|
||||
def _gh_comment(**overrides):
|
||||
"""Build a minimal GitHub-API-shaped comment dict."""
|
||||
base = {
|
||||
"side": "RIGHT",
|
||||
"line": None,
|
||||
"original_line": None,
|
||||
"start_line": None,
|
||||
"original_start_line": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveLine(unittest.TestCase):
|
||||
def test_line_matches(self):
|
||||
assert _resolve_line(_CONTEXT_HUNK, 22, None, "RIGHT") == 22
|
||||
|
||||
def test_line_mismatches_original_matches(self):
|
||||
assert _resolve_line(_CONTEXT_HUNK, 9999, 23, "RIGHT") == 23
|
||||
|
||||
def test_both_mismatch_falls_back_to_last_reachable(self):
|
||||
# last new-file line in the hunk is 24
|
||||
assert _resolve_line(_CONTEXT_HUNK, 9999, 8888, "RIGHT") == 24
|
||||
|
||||
def test_empty_hunk_returns_none(self):
|
||||
assert _resolve_line("", 22, None, "RIGHT") is None
|
||||
|
||||
def test_none_candidates_falls_back_to_last_reachable(self):
|
||||
assert _resolve_line(_CONTEXT_HUNK, None, None, "RIGHT") == 24
|
||||
|
||||
def test_left_side(self):
|
||||
assert _resolve_line(_CONTEXT_HUNK, 12, None, "LEFT") == 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_comment_line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveCommentLine(unittest.TestCase):
|
||||
def test_line_matches_hunk(self):
|
||||
c = _gh_comment(side="RIGHT", line=22)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result == (22, None, "RIGHT")
|
||||
|
||||
def test_line_mismatches_original_matches(self):
|
||||
c = _gh_comment(side="RIGHT", line=9999, original_line=23)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result == (23, None, "RIGHT")
|
||||
|
||||
def test_both_mismatch_returns_last_reachable(self):
|
||||
c = _gh_comment(side="RIGHT", line=9999, original_line=8888)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
# last reachable new-file line is 24
|
||||
assert result == (24, None, "RIGHT")
|
||||
|
||||
def test_both_mismatch_no_reachable_lines_returns_none(self):
|
||||
deletion_only = "@@ -5,3 +5,0 @@\n-del A\n-del B\n-del C"
|
||||
c = _gh_comment(side="RIGHT", line=9999, original_line=8888)
|
||||
result = _resolve_comment_line(c, deletion_only)
|
||||
assert result is None
|
||||
|
||||
def test_empty_hunk_returns_none(self):
|
||||
c = _gh_comment(side="RIGHT", line=22)
|
||||
result = _resolve_comment_line(c, "")
|
||||
assert result is None
|
||||
|
||||
def test_both_line_and_original_none_falls_back(self):
|
||||
c = _gh_comment(side="RIGHT")
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
# No explicit line, but the hunk has reachable lines → last reachable
|
||||
assert result == (24, None, "RIGHT")
|
||||
|
||||
def test_start_line_resolved(self):
|
||||
c = _gh_comment(side="RIGHT", line=23, start_line=21)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result == (23, 21, "RIGHT")
|
||||
|
||||
def test_start_line_falls_back_to_original(self):
|
||||
c = _gh_comment(
|
||||
side="RIGHT", line=23,
|
||||
start_line=9999, original_start_line=20,
|
||||
)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result == (23, 20, "RIGHT")
|
||||
|
||||
def test_side_defaults_to_right(self):
|
||||
c = _gh_comment(line=22)
|
||||
c.pop("side")
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result[2] == "RIGHT"
|
||||
|
||||
# -- Inverted range: start_line > end_line after fallback ----------------
|
||||
|
||||
def test_start_line_cleared_when_inverted(self):
|
||||
"""Both start_line and original_start_line are stale.
|
||||
|
||||
_resolve_line falls back to last_reachable_line (24) for the start,
|
||||
but end_line resolved to an earlier line (21). The fix must clear
|
||||
start_line to None instead of returning an inverted range.
|
||||
"""
|
||||
c = _gh_comment(
|
||||
side="RIGHT",
|
||||
line=21,
|
||||
start_line=9999,
|
||||
original_start_line=8888,
|
||||
)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
end_line, start_line, side = result
|
||||
assert end_line == 21
|
||||
assert start_line is None
|
||||
assert side == "RIGHT"
|
||||
|
||||
def test_start_line_kept_when_not_inverted(self):
|
||||
"""start_line resolves to a line before end_line — should be kept."""
|
||||
c = _gh_comment(
|
||||
side="RIGHT",
|
||||
line=23,
|
||||
start_line=21,
|
||||
)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result == (23, 21, "RIGHT")
|
||||
|
||||
def test_start_line_cleared_when_equal_to_end(self):
|
||||
"""start_line == end_line is fine — only strictly greater is inverted."""
|
||||
c = _gh_comment(
|
||||
side="RIGHT",
|
||||
line=22,
|
||||
start_line=22,
|
||||
)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
assert result == (22, 22, "RIGHT")
|
||||
|
||||
def test_start_fallback_exceeds_end_on_left_side(self):
|
||||
"""Same inverted-range scenario but on the LEFT side."""
|
||||
c = _gh_comment(
|
||||
side="LEFT",
|
||||
line=11,
|
||||
start_line=9999,
|
||||
original_start_line=8888,
|
||||
)
|
||||
result = _resolve_comment_line(c, _CONTEXT_HUNK)
|
||||
end_line, start_line, side = result
|
||||
assert end_line == 11
|
||||
# last_reachable_line on LEFT = 14, which > 11 → cleared
|
||||
assert start_line is None
|
||||
assert side == "LEFT"
|
||||
|
||||
# -- Regression: PR #22932 outdated comment ----------------------------
|
||||
|
||||
def test_regression_outdated_comment_falls_back(self):
|
||||
"""Comment 2898341466: line=2899 (repositioned), hunk +3066.
|
||||
|
||||
Neither line nor original_line is in the hunk, so we expect the
|
||||
last reachable line (3071 in the original 6-body-line hunk).
|
||||
"""
|
||||
hunk = (
|
||||
"@@ -2926,6 +3066,17 @@ fn render_response_footer\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" \n"
|
||||
"+ // Bulk-import review comments button\n"
|
||||
"+ // has any imported review comments.\n"
|
||||
"+ if props.conversation_has_imported_comments {"
|
||||
)
|
||||
c = _gh_comment(side="RIGHT", line=2899, original_line=2899)
|
||||
result = _resolve_comment_line(c, hunk)
|
||||
assert result is not None
|
||||
end_line, _start, side = result
|
||||
assert side == "RIGHT"
|
||||
# Should fall back to last reachable new-file line in the hunk body.
|
||||
assert end_line == 3071
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Tests for trim_diff_hunk and _parse_hunk_header.
|
||||
|
||||
Reference: app/src/code_review/comments/diff_hunk_parser_tests.rs
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from trim_diff_hunk import trim_diff_hunk, _parse_hunk_header, _prepare_lines, _annotate_hunk_body, line_in_hunk, last_reachable_line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper to build large hunks for testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_context_hunk(old_start, new_start, count):
|
||||
"""Build a pure-context hunk with ``count`` lines."""
|
||||
header = f"@@ -{old_start},{count} +{new_start},{count} @@"
|
||||
body = [f" line {old_start + i}" for i in range(count)]
|
||||
return "\n".join([header] + body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _prepare_lines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPrepareLines(unittest.TestCase):
|
||||
def test_splits_and_strips_trailing_empties(self):
|
||||
assert _prepare_lines("a\nb\nc\n\n") == ["a", "b", "c"]
|
||||
|
||||
def test_no_trailing_empties(self):
|
||||
assert _prepare_lines("a\nb") == ["a", "b"]
|
||||
|
||||
def test_single_line(self):
|
||||
assert _prepare_lines("@@ -1,1 +1,1 @@") == ["@@ -1,1 +1,1 @@"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _prepare_lines("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_hunk_header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseHunkHeader(unittest.TestCase):
|
||||
def test_standard_header(self):
|
||||
assert _parse_hunk_header("@@ -10,5 +20,7 @@") == (10, 5, 20, 7, "")
|
||||
|
||||
def test_header_with_context_text(self):
|
||||
result = _parse_hunk_header("@@ -10,5 +20,7 @@ fn main()")
|
||||
assert result == (10, 5, 20, 7, " fn main()")
|
||||
|
||||
def test_omitted_counts_default_to_one(self):
|
||||
assert _parse_hunk_header("@@ -10 +20 @@") == (10, 1, 20, 1, "")
|
||||
|
||||
def test_invalid_headers(self):
|
||||
assert _parse_hunk_header("not a header") is None
|
||||
assert _parse_hunk_header("@@ invalid @@") is None
|
||||
assert _parse_hunk_header("") is None
|
||||
|
||||
def test_only_old_count_omitted(self):
|
||||
assert _parse_hunk_header("@@ -10 +20,3 @@") == (10, 1, 20, 3, "")
|
||||
|
||||
def test_only_new_count_omitted(self):
|
||||
assert _parse_hunk_header("@@ -10,3 +20 @@") == (10, 3, 20, 1, "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – basic / passthrough cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTrimPassthrough(unittest.TestCase):
|
||||
"""Cases where trim_diff_hunk should return the input unchanged."""
|
||||
|
||||
def test_empty_string(self):
|
||||
assert trim_diff_hunk("", 1) == ""
|
||||
|
||||
def test_none_input(self):
|
||||
assert trim_diff_hunk(None, 1) is None
|
||||
|
||||
def test_small_hunk_unchanged(self):
|
||||
hunk = "@@ -1,2 +1,3 @@\n first line\n+added line\n last line"
|
||||
assert trim_diff_hunk(hunk, 2, context_lines=3) == hunk
|
||||
|
||||
def test_invalid_header_unchanged(self):
|
||||
hunk = "not a valid header\n+line1\n+line2\n+line3\n+line4\n+line5"
|
||||
assert trim_diff_hunk(hunk, 1) == hunk
|
||||
|
||||
def test_target_not_found_unchanged(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
assert trim_diff_hunk(hunk, 999) == hunk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – trimming behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTrimBehaviour(unittest.TestCase):
|
||||
def test_trims_to_context_window(self):
|
||||
"""Large hunk trimmed to ±3 lines around target."""
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
result = trim_diff_hunk(hunk, 10, context_lines=3)
|
||||
result_lines = result.split("\n")
|
||||
|
||||
# Target ± 3 → lines 7-13 = 7 body lines + header
|
||||
assert result_lines[0] == "@@ -7,7 +7,7 @@"
|
||||
assert " line 7" in result
|
||||
assert " line 10" in result
|
||||
assert " line 13" in result
|
||||
assert " line 6" not in result
|
||||
assert " line 14" not in result
|
||||
|
||||
def test_target_at_beginning(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
result = trim_diff_hunk(hunk, 1, context_lines=3)
|
||||
result_lines = result.split("\n")
|
||||
|
||||
# Can't go before line 1 → lo=0, hi=3
|
||||
assert result_lines[0] == "@@ -1,4 +1,4 @@"
|
||||
assert " line 1" in result
|
||||
assert " line 4" in result
|
||||
assert " line 5" not in result
|
||||
|
||||
def test_target_at_end(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
result = trim_diff_hunk(hunk, 20, context_lines=3)
|
||||
result_lines = result.split("\n")
|
||||
|
||||
assert result_lines[0] == "@@ -17,4 +17,4 @@"
|
||||
assert " line 17" in result
|
||||
assert " line 20" in result
|
||||
assert " line 16" not in result
|
||||
|
||||
def test_preserves_whitespace(self):
|
||||
"""Mirrors Rust test_parse_preserves_whitespace."""
|
||||
lines = ["@@ -1,15 +1,16 @@"]
|
||||
for i in range(1, 8):
|
||||
lines.append(f" line {i}")
|
||||
lines.append("+ heavily indented") # new line 8
|
||||
for i in range(8, 16):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 8, side="RIGHT")
|
||||
assert "+ heavily indented" in result
|
||||
|
||||
def test_preserves_header_context_text(self):
|
||||
lines = ["@@ -1,20 +1,20 @@ fn example()"]
|
||||
for i in range(1, 21):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 10)
|
||||
assert result.split("\n")[0].endswith(" fn example()")
|
||||
|
||||
def test_trailing_empty_lines_stripped(self):
|
||||
hunk = _make_context_hunk(1, 1, 20) + "\n\n\n"
|
||||
result = trim_diff_hunk(hunk, 10)
|
||||
assert not result.endswith("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – LEFT / RIGHT side targeting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSideTargeting(unittest.TestCase):
|
||||
def test_right_side_skips_deletions(self):
|
||||
"""RIGHT side tracks new-file line numbers; deletions are invisible."""
|
||||
lines = ["@@ -1,12 +1,14 @@"]
|
||||
for i in range(1, 4):
|
||||
lines.append(f" line {i}") # old 1-3, new 1-3
|
||||
lines.append("-deleted A") # old 4
|
||||
lines.append("-deleted B") # old 5
|
||||
lines.append("+added A") # new 4
|
||||
lines.append("+added B") # new 5
|
||||
lines.append("+added C") # new 6
|
||||
lines.append("+added D") # new 7
|
||||
for i in range(6, 14):
|
||||
lines.append(f" line {i}") # old 6-13, new 8-15
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 6, side="RIGHT")
|
||||
assert "+added C" in result
|
||||
|
||||
def test_left_side_skips_additions(self):
|
||||
"""LEFT side tracks old-file line numbers; additions are invisible."""
|
||||
lines = ["@@ -10,15 +10,16 @@"]
|
||||
for i in range(15):
|
||||
if i == 7:
|
||||
lines.append("+added line") # new-only, no old num
|
||||
lines.append(f" context {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
# old line 15 = old_start(10) + 5 context lines → " context 5"
|
||||
result = trim_diff_hunk(hunk, 15, side="LEFT")
|
||||
assert " context 5" in result
|
||||
|
||||
def test_left_targets_deletion(self):
|
||||
"""Targeting a deleted line by old-file number."""
|
||||
lines = ["@@ -1,12 +1,10 @@"]
|
||||
for i in range(1, 5):
|
||||
lines.append(f" line {i}")
|
||||
lines.append("-removed A") # old 5
|
||||
lines.append("-removed B") # old 6
|
||||
for i in range(5, 13):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 5, side="LEFT")
|
||||
assert "-removed A" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – multi-line comment ranges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultilineRange(unittest.TestCase):
|
||||
def test_keeps_full_range_plus_context(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
# start_line=8, target=12, context=3 → window is [8-3, 12+3] = [5, 15]
|
||||
result = trim_diff_hunk(hunk, 12, start_line=8, context_lines=3)
|
||||
result_lines = result.split("\n")
|
||||
|
||||
assert result_lines[0] == "@@ -5,11 +5,11 @@"
|
||||
assert " line 5" in result
|
||||
assert " line 8" in result
|
||||
assert " line 12" in result
|
||||
assert " line 15" in result
|
||||
assert " line 4" not in result
|
||||
assert " line 16" not in result
|
||||
|
||||
def test_range_start_at_hunk_boundary(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
# start_line=1, context=3 → lo clamped to 0
|
||||
result = trim_diff_hunk(hunk, 5, start_line=1, context_lines=3)
|
||||
assert " line 1" in result
|
||||
assert " line 8" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – pure additions / pure deletions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPureAdditionsAndDeletions(unittest.TestCase):
|
||||
def test_only_additions(self):
|
||||
"""Old-file count should be 0 in trimmed header."""
|
||||
lines = ["@@ -5,0 +5,15 @@"]
|
||||
for i in range(5, 20):
|
||||
lines.append(f"+new line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 10, side="RIGHT")
|
||||
header = result.split("\n")[0]
|
||||
# All trimmed lines are additions → old count = 0
|
||||
assert ",0 +" in header
|
||||
assert "+new line 10" in result
|
||||
|
||||
def test_only_deletions(self):
|
||||
"""New-file count should be 0 in trimmed header."""
|
||||
lines = ["@@ -5,15 +5,0 @@"]
|
||||
for i in range(5, 20):
|
||||
lines.append(f"-old line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 10, side="LEFT")
|
||||
header = result.split("\n")[0]
|
||||
assert "+5,0 @@" in header
|
||||
assert "-old line 10" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – special markers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpecialMarkers(unittest.TestCase):
|
||||
def test_no_newline_marker_does_not_shift_line_numbers(self):
|
||||
r"""'\ No newline at end of file' must not affect line counting."""
|
||||
lines = ["@@ -1,15 +1,16 @@"]
|
||||
for i in range(1, 8):
|
||||
lines.append(f" line {i}")
|
||||
lines.append("+added line") # new line 8
|
||||
lines.append("\\ No newline at end of file")
|
||||
for i in range(8, 16):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
# Use context=3 so the marker is within the trim window
|
||||
result = trim_diff_hunk(hunk, 8, side="RIGHT", context_lines=3)
|
||||
assert "+added line" in result
|
||||
|
||||
def test_no_newline_marker_excluded_from_counts(self):
|
||||
r"""The marker should not inflate old/new counts in the header."""
|
||||
lines = ["@@ -1,15 +1,16 @@"]
|
||||
for i in range(1, 8):
|
||||
lines.append(f" line {i}")
|
||||
lines.append("+added line") # new line 8
|
||||
lines.append("\\ No newline at end of file")
|
||||
for i in range(8, 16):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
# Use context=3 so the marker is within the trim window
|
||||
result = trim_diff_hunk(hunk, 8, side="RIGHT", context_lines=3)
|
||||
header = result.split("\n")[0]
|
||||
parsed = _parse_hunk_header(header)
|
||||
old_count, new_count = parsed[1], parsed[3]
|
||||
# Count body lines manually to verify
|
||||
body = result.split("\n")[1:]
|
||||
expected_old = sum(
|
||||
1 for l in body if l and l[0] not in ("+", "\\")
|
||||
)
|
||||
expected_new = sum(
|
||||
1 for l in body if l and l[0] not in ("-", "\\")
|
||||
)
|
||||
assert old_count == expected_old
|
||||
assert new_count == expected_new
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – zero context (default)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestZeroContext(unittest.TestCase):
|
||||
"""With context_lines=0, only the exact commented line(s) are kept."""
|
||||
|
||||
def test_isolates_single_context_line(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
result = trim_diff_hunk(hunk, 10)
|
||||
result_lines = result.split("\n")
|
||||
|
||||
assert result_lines[0] == "@@ -10,1 +10,1 @@"
|
||||
assert len(result_lines) == 2
|
||||
assert " line 10" in result
|
||||
|
||||
def test_isolates_addition(self):
|
||||
lines = ["@@ -1,5 +1,6 @@"]
|
||||
for i in range(1, 4):
|
||||
lines.append(f" line {i}")
|
||||
lines.append("+added line") # new line 4
|
||||
for i in range(4, 6):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 4, side="RIGHT")
|
||||
result_lines = result.split("\n")
|
||||
assert len(result_lines) == 2
|
||||
assert "+added line" in result
|
||||
header = _parse_hunk_header(result_lines[0])
|
||||
assert header[1] == 0 # old count = 0
|
||||
assert header[3] == 1 # new count = 1
|
||||
|
||||
def test_isolates_deletion(self):
|
||||
lines = ["@@ -1,6 +1,5 @@"]
|
||||
for i in range(1, 4):
|
||||
lines.append(f" line {i}")
|
||||
lines.append("-deleted line") # old line 4
|
||||
for i in range(5, 7):
|
||||
lines.append(f" line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
|
||||
result = trim_diff_hunk(hunk, 4, side="LEFT")
|
||||
result_lines = result.split("\n")
|
||||
assert len(result_lines) == 2
|
||||
assert "-deleted line" in result
|
||||
header = _parse_hunk_header(result_lines[0])
|
||||
assert header[1] == 1 # old count = 1
|
||||
assert header[3] == 0 # new count = 0
|
||||
|
||||
def test_multiline_range_no_context(self):
|
||||
hunk = _make_context_hunk(1, 1, 20)
|
||||
result = trim_diff_hunk(hunk, 12, start_line=8)
|
||||
result_lines = result.split("\n")
|
||||
|
||||
assert result_lines[0] == "@@ -8,5 +8,5 @@"
|
||||
assert " line 8" in result
|
||||
assert " line 12" in result
|
||||
assert " line 7" not in result
|
||||
assert " line 13" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# line_in_hunk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLineInHunk(unittest.TestCase):
|
||||
def test_target_within_range(self):
|
||||
hunk = _make_context_hunk(10, 20, 5)
|
||||
assert line_in_hunk(hunk, 22, side="RIGHT") is True
|
||||
|
||||
def test_target_within_range_left(self):
|
||||
hunk = _make_context_hunk(10, 20, 5)
|
||||
assert line_in_hunk(hunk, 12, side="LEFT") is True
|
||||
|
||||
def test_target_past_end(self):
|
||||
hunk = _make_context_hunk(10, 20, 5) # new 20-24
|
||||
assert line_in_hunk(hunk, 30, side="RIGHT") is False
|
||||
|
||||
def test_target_before_start(self):
|
||||
hunk = _make_context_hunk(10, 20, 5) # new 20-24
|
||||
assert line_in_hunk(hunk, 5, side="RIGHT") is False
|
||||
|
||||
def test_right_target_in_deletion_only_hunk(self):
|
||||
"""A hunk with only deletions has no reachable RIGHT lines."""
|
||||
hunk = "@@ -5,3 +5,0 @@\n-del A\n-del B\n-del C"
|
||||
assert line_in_hunk(hunk, 5, side="RIGHT") is False
|
||||
|
||||
def test_empty_hunk(self):
|
||||
assert line_in_hunk("", 1) is False
|
||||
|
||||
def test_none_hunk(self):
|
||||
assert line_in_hunk(None, 1) is False
|
||||
|
||||
def test_target_past_hunk_end(self):
|
||||
"""Comment 2892844016: target 11078, hunk new 10816-10822."""
|
||||
hunk = (
|
||||
"@@ -10808,9 +10816,7 @@ impl Workspace {\n"
|
||||
" comment,\n"
|
||||
" diff_mode,\n"
|
||||
" } => {\n"
|
||||
"- if !pane_group.as_ref(ctx).right_panel_open {\n"
|
||||
"- self.open_code_review_panel_from_arg(open_code_review, pane_group.clone(), ctx);\n"
|
||||
"- }\n"
|
||||
"+ self.open_code_review_panel_from_arg(open_code_review, pane_group.clone(), ctx);"
|
||||
)
|
||||
assert line_in_hunk(hunk, 11078, side="RIGHT") is False
|
||||
|
||||
def test_target_before_hunk_start(self):
|
||||
"""Comment 2898341466: target 2899, hunk new 3066-3082."""
|
||||
hunk = (
|
||||
"@@ -2926,6 +3066,17 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" \n"
|
||||
"+ // Bulk-import review comments button, shown on the latest exchange when the conversation\n"
|
||||
"+ // has any imported review comments.\n"
|
||||
"+ if props.conversation_has_imported_comments && !props.shared_session_status.is_viewer() {"
|
||||
)
|
||||
assert line_in_hunk(hunk, 2899, side="RIGHT") is False
|
||||
|
||||
def test_truncated_hunk(self):
|
||||
"""Comment 2954102612: header claims +4770,109 but body has only 5 lines.
|
||||
|
||||
Simulates a truncated hunk where the target (4918) is past the body.
|
||||
"""
|
||||
lines = ["@@ -4769,6 +4770,109 @@ impl AIBlock {"]
|
||||
for i in range(4770, 4775):
|
||||
lines.append(f"+ line {i}")
|
||||
hunk = "\n".join(lines)
|
||||
assert line_in_hunk(hunk, 4918, side="RIGHT") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# last_reachable_line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLastReachableLine(unittest.TestCase):
|
||||
def test_context_hunk(self):
|
||||
hunk = _make_context_hunk(10, 20, 5) # new 20-24
|
||||
assert last_reachable_line(hunk, side="RIGHT") == 24
|
||||
|
||||
def test_addition_hunk(self):
|
||||
hunk = "@@ -5,0 +5,3 @@\n+new A\n+new B\n+new C"
|
||||
assert last_reachable_line(hunk, side="RIGHT") == 7
|
||||
|
||||
def test_deletion_only_right(self):
|
||||
hunk = "@@ -5,3 +5,0 @@\n-del A\n-del B\n-del C"
|
||||
assert last_reachable_line(hunk, side="RIGHT") is None
|
||||
|
||||
def test_deletion_only_left(self):
|
||||
hunk = "@@ -5,3 +5,0 @@\n-del A\n-del B\n-del C"
|
||||
assert last_reachable_line(hunk, side="LEFT") == 7
|
||||
|
||||
def test_empty_hunk(self):
|
||||
assert last_reachable_line("", side="RIGHT") is None
|
||||
|
||||
def test_none_hunk(self):
|
||||
assert last_reachable_line(None, side="RIGHT") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk – multi-hunk inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiHunk(unittest.TestCase):
|
||||
"""trim_diff_hunk should find and trim the correct sub-hunk."""
|
||||
|
||||
_MULTI = (
|
||||
"@@ -1,3 +1,4 @@ fn foo()\n"
|
||||
" ctx1\n"
|
||||
"+added_early\n"
|
||||
" ctx2\n"
|
||||
" ctx3\n"
|
||||
"@@ -100,3 +101,4 @@ fn bar()\n"
|
||||
" ctx100\n"
|
||||
"+added_late\n"
|
||||
" ctx101\n"
|
||||
" ctx102"
|
||||
)
|
||||
|
||||
def test_target_in_second_hunk(self):
|
||||
"""Target 102 (new-file line in second sub-hunk) is found and trimmed."""
|
||||
result = trim_diff_hunk(self._MULTI, 102, side="RIGHT")
|
||||
assert "+added_late" in result
|
||||
# The result should NOT contain the first hunk's content.
|
||||
assert "+added_early" not in result
|
||||
header = _parse_hunk_header(result.split("\n")[0])
|
||||
assert header is not None
|
||||
# new_start should be from the second hunk's range (101+)
|
||||
assert header[2] == 102 # trimmed to just line 102
|
||||
|
||||
def test_target_in_first_hunk(self):
|
||||
result = trim_diff_hunk(self._MULTI, 2, side="RIGHT")
|
||||
assert "+added_early" in result
|
||||
assert "+added_late" not in result
|
||||
|
||||
def test_target_in_no_hunk(self):
|
||||
result = trim_diff_hunk(self._MULTI, 9999, side="RIGHT")
|
||||
assert result == self._MULTI
|
||||
|
||||
def test_multi_hunk_line_in_hunk(self):
|
||||
"""line_in_hunk should search across all sub-hunks."""
|
||||
assert line_in_hunk(self._MULTI, 102, side="RIGHT") is True
|
||||
assert line_in_hunk(self._MULTI, 2, side="RIGHT") is True
|
||||
assert line_in_hunk(self._MULTI, 9999, side="RIGHT") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge case: markdown list items in pure-addition hunks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMarkdownListItem(unittest.TestCase):
|
||||
r"""Hunks from where commented lines are markdown list items.
|
||||
|
||||
The line `+- \`specs/<issue-number>/TECH.md\`` starts with `+-`. The `-`
|
||||
is a markdown list marker, NOT a diff deletion prefix.
|
||||
"""
|
||||
|
||||
_HUNK = (
|
||||
"@@ -0,0 +1,116 @@\n"
|
||||
"+---\n"
|
||||
"+name: write-tech-spec\n"
|
||||
"+description: desc\n"
|
||||
"+---\n"
|
||||
"+\n"
|
||||
"+# write-tech-spec\n"
|
||||
"+\n"
|
||||
"+Write a spec.\n"
|
||||
"+\n"
|
||||
"+## Overview\n"
|
||||
"+\n"
|
||||
"+The tech spec overview.\n"
|
||||
"+\n"
|
||||
"+Write specs into source control under:\n"
|
||||
"+\n"
|
||||
"+- `specs/<issue-number>/TECH.md`"
|
||||
)
|
||||
|
||||
def test_annotate_hunk_body_classifies_plus_dash_as_addition(self):
|
||||
"""_annotate_hunk_body must treat `+-` lines as additions."""
|
||||
body = _prepare_lines(self._HUNK)[1:] # skip header
|
||||
annotated = _annotate_hunk_body(body, 0, 1)
|
||||
# Line 16 (new-file) should be the markdown list item.
|
||||
target_entry = annotated[15] # 0-indexed
|
||||
old_num, new_num, text = target_entry
|
||||
assert old_num is None, f"Expected no old-file line number, got {old_num}"
|
||||
assert new_num == 16, f"Expected new-file line 16, got {new_num}"
|
||||
assert text == "+- `specs/<issue-number>/TECH.md`"
|
||||
|
||||
def test_trim_preserves_plus_dash_line(self):
|
||||
"""trim_diff_hunk must preserve the full `+-` line text."""
|
||||
result = trim_diff_hunk(self._HUNK, 16, side="RIGHT")
|
||||
assert "+- `specs/<issue-number>/TECH.md`" in result
|
||||
|
||||
def test_trim_header_counts_plus_dash_as_new(self):
|
||||
"""Trimmed header must count the `+-` line as a new-file line."""
|
||||
result = trim_diff_hunk(self._HUNK, 16, side="RIGHT")
|
||||
header = _parse_hunk_header(result.split("\n")[0])
|
||||
assert header is not None
|
||||
old_count, new_count = header[1], header[3]
|
||||
# A single addition line: old_count=0, new_count=1
|
||||
assert old_count == 0, f"Expected old_count=0, got {old_count}"
|
||||
assert new_count == 1, f"Expected new_count=1, got {new_count}"
|
||||
|
||||
def test_line_in_hunk_finds_plus_dash_line(self):
|
||||
"""line_in_hunk must locate line 16 on the RIGHT side."""
|
||||
assert line_in_hunk(self._HUNK, 16, side="RIGHT") is True
|
||||
|
||||
def test_last_reachable_line_includes_plus_dash(self):
|
||||
"""last_reachable_line must reach line 16 on the RIGHT side."""
|
||||
assert last_reachable_line(self._HUNK, side="RIGHT") == 16
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Diff-hunk trimming for unified diffs.
|
||||
|
||||
Mirrors the logic in app/src/code_review/comments/diff_hunk_parser.rs:
|
||||
walk hunk lines tracking old/new file line numbers, locate the target line,
|
||||
then trim unneeded lines from the start and end (never the middle) and
|
||||
rewrite the hunk header to match the trimmed window.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_HUNK_HEADER_RE = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_hunk_header(line):
|
||||
m = _HUNK_HEADER_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
return (
|
||||
int(m.group(1)),
|
||||
int(m.group(2)) if m.group(2) is not None else 1,
|
||||
int(m.group(3)),
|
||||
int(m.group(4)) if m.group(4) is not None else 1,
|
||||
m.group(5),
|
||||
)
|
||||
|
||||
|
||||
def _annotate_hunk_body(body_lines, old_start, new_start):
|
||||
"""Annotate *body_lines* with ``(old_num | None, new_num | None, text)``."""
|
||||
old_num, new_num = old_start, new_start
|
||||
annotated = []
|
||||
for text in body_lines:
|
||||
ch = text[0] if text else " "
|
||||
if ch == "+":
|
||||
annotated.append((None, new_num, text))
|
||||
new_num += 1
|
||||
elif ch == "-":
|
||||
annotated.append((old_num, None, text))
|
||||
old_num += 1
|
||||
elif ch == "\\":
|
||||
annotated.append((None, None, text))
|
||||
else:
|
||||
annotated.append((old_num, new_num, text))
|
||||
old_num += 1
|
||||
new_num += 1
|
||||
return annotated
|
||||
|
||||
|
||||
def _split_hunks(lines):
|
||||
"""Split *lines* (with trailing empties already stripped) into sub-hunks.
|
||||
|
||||
Returns a list of ``(header_tuple, header_text, body_lines)`` where
|
||||
*header_tuple* is the parsed ``(old_start, old_count, new_start, new_count,
|
||||
ctx)`` and *body_lines* are the raw diff body strings.
|
||||
"""
|
||||
hunks = []
|
||||
current_header = None
|
||||
current_header_text = None
|
||||
current_body = []
|
||||
|
||||
for line in lines:
|
||||
parsed = _parse_hunk_header(line)
|
||||
if parsed is not None:
|
||||
if current_header is not None:
|
||||
hunks.append((current_header, current_header_text, current_body))
|
||||
current_header = parsed
|
||||
current_header_text = line
|
||||
current_body = []
|
||||
else:
|
||||
current_body.append(line)
|
||||
|
||||
if current_header is not None:
|
||||
hunks.append((current_header, current_header_text, current_body))
|
||||
|
||||
return hunks
|
||||
|
||||
|
||||
def _find_target_idx(annotated, target_line, use_new):
|
||||
"""Return the index into *annotated* where the target line lives, or None."""
|
||||
for i, (o, n, _) in enumerate(annotated):
|
||||
num = n if use_new else o
|
||||
if num is not None and num == target_line:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers used by fetch_github_review_comments.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _prepare_lines(diff_hunk):
|
||||
"""Split *diff_hunk* into lines and strip trailing empty strings."""
|
||||
lines = diff_hunk.split("\n")
|
||||
while lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def line_in_hunk(diff_hunk, target_line, side="RIGHT"):
|
||||
"""Return ``True`` if *target_line* is reachable on *side* of the hunk."""
|
||||
if not diff_hunk:
|
||||
return False
|
||||
|
||||
lines = _prepare_lines(diff_hunk)
|
||||
use_new = side != "LEFT"
|
||||
|
||||
for header, _, body in _split_hunks(lines):
|
||||
old_start, _, new_start, _, _ = header
|
||||
annotated = _annotate_hunk_body(body, old_start, new_start)
|
||||
if _find_target_idx(annotated, target_line, use_new) is not None:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def last_reachable_line(diff_hunk, side="RIGHT"):
|
||||
"""Return the last line number reachable on *side*, or ``None``."""
|
||||
if not diff_hunk:
|
||||
return None
|
||||
|
||||
lines = _prepare_lines(diff_hunk)
|
||||
use_new = side != "LEFT"
|
||||
last = None
|
||||
|
||||
for header, _, body in _split_hunks(lines):
|
||||
old_start, _, new_start, _, _ = header
|
||||
for o, n, _ in _annotate_hunk_body(body, old_start, new_start):
|
||||
num = n if use_new else o
|
||||
if num is not None:
|
||||
last = num
|
||||
|
||||
return last
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim_diff_hunk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def trim_diff_hunk(diff_hunk, target_line, side="RIGHT", start_line=None, context_lines=0):
|
||||
"""Return *diff_hunk* trimmed to ±*context_lines* around *target_line*.
|
||||
|
||||
The hunk header is rewritten so the line numbers stay correct.
|
||||
If the hunk is already small enough, it is returned unchanged.
|
||||
"""
|
||||
if not diff_hunk:
|
||||
return diff_hunk
|
||||
|
||||
lines = _prepare_lines(diff_hunk)
|
||||
|
||||
hunks = _split_hunks(lines)
|
||||
if not hunks:
|
||||
return diff_hunk
|
||||
|
||||
use_new = side != "LEFT"
|
||||
|
||||
# Find the sub-hunk that contains the target line.
|
||||
for header, _header_text, body in hunks:
|
||||
old_start, _, new_start, _, hdr_ctx = header
|
||||
annotated = _annotate_hunk_body(body, old_start, new_start)
|
||||
|
||||
target_idx = _find_target_idx(annotated, target_line, use_new)
|
||||
if target_idx is None:
|
||||
continue
|
||||
|
||||
# Found the right sub-hunk — trim within it.
|
||||
if len(annotated) <= context_lines * 2 + 1:
|
||||
# Small enough already — return just this sub-hunk.
|
||||
new_hdr = _rewrite_header(annotated, old_start, new_start, hdr_ctx)
|
||||
return "\n".join([new_hdr] + [t for _, _, t in annotated])
|
||||
|
||||
range_start_idx = None
|
||||
if start_line:
|
||||
range_start_idx = _find_target_idx(annotated, start_line, use_new)
|
||||
|
||||
first = range_start_idx if range_start_idx is not None else target_idx
|
||||
lo = max(0, first - context_lines)
|
||||
hi = min(len(annotated) - 1, target_idx + context_lines)
|
||||
trimmed = annotated[lo : hi + 1]
|
||||
|
||||
new_hdr = _rewrite_header(trimmed, old_start, new_start, hdr_ctx)
|
||||
return "\n".join([new_hdr] + [t for _, _, t in trimmed])
|
||||
|
||||
# Target not found in any sub-hunk — return original unchanged.
|
||||
return diff_hunk
|
||||
|
||||
|
||||
def _rewrite_header(trimmed, old_start, new_start, hdr_ctx):
|
||||
"""Build a unified diff header from a trimmed annotation window."""
|
||||
t_os = t_ns = None
|
||||
t_oc = t_nc = 0
|
||||
for o, n, text in trimmed:
|
||||
ch = text[0] if text else " "
|
||||
if ch == "+":
|
||||
t_nc += 1
|
||||
if t_ns is None and n is not None:
|
||||
t_ns = n
|
||||
elif ch == "-":
|
||||
t_oc += 1
|
||||
if t_os is None and o is not None:
|
||||
t_os = o
|
||||
elif ch == "\\":
|
||||
pass
|
||||
else:
|
||||
t_oc += 1
|
||||
t_nc += 1
|
||||
if t_os is None and o is not None:
|
||||
t_os = o
|
||||
if t_ns is None and n is not None:
|
||||
t_ns = n
|
||||
|
||||
return f"@@ -{t_os or old_start},{t_oc} +{t_ns or new_start},{t_nc} @@{hdr_ctx}"
|
||||
Reference in New Issue
Block a user