Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: pr-comments
|
||||
description: "Fetch and display GitHub PR review comments for the current branch."
|
||||
---
|
||||
|
||||
# Fetch PR Comments
|
||||
|
||||
Fetch all review comments from the current branch's GitHub PR and display them via `insert_code_review_comments`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Run the bundled script (must be inside a git repo with an open PR on the current branch).
|
||||
Use `do_not_summarize_output: true` when running this shell command so the JSON output is not truncated.
|
||||
```bash
|
||||
python3 <skill_dir>/scripts/fetch_github_review_comments.py
|
||||
```
|
||||
The script prints JSON to stdout.
|
||||
If the script fails to fetch comments, run the fallback `gh` commands instead.
|
||||
|
||||
2. Call `insert_code_review_comments` with the three top-level fields from the JSON output:
|
||||
- `local_repository_path`
|
||||
- `base_branch`
|
||||
- `comments`
|
||||
|
||||
3. Stop and wait for the user. After displaying each batch of comments, you MUST ask the user how they would like to proceed. Do NOT take any further action until the user provides explicit instructions unless the user explicitly asks you to.
|
||||
Do NOT make code changes in response to the fetched comments unless the user tells you to. Do NOT impersonate the user by submitting review responses.
|
||||
Your role when fetching and displaying comments is purely informational — present the comments and wait for direction.
|
||||
|
||||
## What the Script Handles
|
||||
|
||||
- Fetches issue comments, diff comments, and reviews via `gh api --paginate`
|
||||
- Trims large diff hunks to a window around the commented line
|
||||
- Sets `reply_metadata` on reply comments
|
||||
- Sets `location_metadata` on top-level diff comments (filepath, trimmed diff hunk, line, side)
|
||||
- PR-level comments (issue comments and reviews) have neither location nor reply metadata
|
||||
|
||||
## Script fallback commands
|
||||
|
||||
If the script fails to fetch comments, follow these steps to fetch comments directly from the GitHub API:
|
||||
|
||||
1. Use the GitHub cli to find the PR number, owner name, repo name, and PR base branch for the current branch.
|
||||
|
||||
2. Use the GitHub /repos/{owner_login}/{repo_name}/issues/{pr_number}/comments endpoint to fetch PR-level comments.
|
||||
|
||||
3. Use the GitHub /repos/{owner_login}/{repo_name}/pulls/{pr_number}/comments endpoint to fetch line- and file-attached review comments. Remove location metadata and diff hunks from thread replies.
|
||||
|
||||
4. Use the GitHub /repos/{owner_login}/{repo_name}/pulls/{pr_number}/reviews endpoint with a filter to fetch code reviews with comment text.
|
||||
|
||||
5. Invoke the `insert_code_review_comments` tool to send the comments to the user. Include all PR-, review-, file- and line-level comments. If there are no comments on the PR, use the tool to return an empty list. DO NOT read out the comment contents without the tool.
|
||||
|
||||
Ensure the pager is not used by clearing the GH_PAGER environment variable. For example, on MacOS using zsh, use:
|
||||
```sh
|
||||
$ GH_PAGER="" gh pr view --json number,headRepository,headRepositoryOwner,baseRefName
|
||||
$ GH_PAGER="" gh api /repos/{owner_login}/{repo_name}/issues/{pr_number}/comments --jq '.[] | {id, html_url, user_login: .user.login, body, created_at, updated_at}'
|
||||
$ GH_PAGER="" gh api /repos/{owner_login}/{repo_name}/pulls/{pr_number}/comments --jq '.[] | {id, html_url, diff_hunk, path, user_login: .user.login, body, created_at, updated_at, start_line, original_start_line, start_side, line, original_line, side, in_reply_to_id, subject_type} | if .in_reply_to_id != null then del(.diff_hunk, .path, .line, .original_line, .start_line, .original_start_line, .side, .start_side, .subject_type) else . end'
|
||||
$ GH_PAGER="" gh api /repos/{owner_login}/{repo_name}/pulls/{pr_number}/reviews --jq '.[] | {id, html_url, user_login: .user.login, body, created_at, updated_at} | select(.body != "" and .body != null)'
|
||||
```
|
||||
Adapt the instructions above for the user's operating system and shell. Then invoke the `insert_code_review_comments` tool.
|
||||
|
||||
6. After displaying comments, follow step 3 of the Procedure above: stop and ask the user how they want to proceed. Do NOT take any action on the comments without explicit user direction.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `gh` CLI authenticated with repo access
|
||||
- Current branch has an open pull request
|
||||
@@ -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