fix(robomp): thread commit_id/head_sha/patch through proxy backend
Follow-up fix round for 71d608aae6 (validate PR review comment
anchors against the diff before submitting). The validation path
broke in several places the original commit did not cover:
- GitHubBackend.submit_review gained no commit_id parameter, so
submitting through GitHubProxyClient raised a production
TypeError on a path the validation code depended on.
- PullRequestInfo lacked head_sha, so the Forgejo commit_id
fallback raised AttributeError; it is now parsed in
_pr_from_payload and carried through the proxy round-trip.
- _pr_file_from dropped the file patch, silently no-oping anchor
validation for anything routed through the proxy; the patch is
now forwarded.
- The hunk parser treated any +++/--- line as a file header,
desyncing line counters when added/removed content began with
those prefixes; file headers are now recognized only before
the first hunk.
- Reworded the comment to "Forgejo only" to match the actual
backend behavior.
Adds tests for forgejo commit_id fetch, fallback double-failure,
empty-patch fail-open, LEFT-side anchoring, proxy commit_id
validation, and file-creation hunk boundaries.
This commit is contained in:
@@ -100,6 +100,7 @@ class GitHubBackend(Protocol):
|
||||
body: str,
|
||||
event: str,
|
||||
comments: list[Mapping[str, Any]],
|
||||
commit_id: str | None = None,
|
||||
) -> PullRequestReviewInfo: ...
|
||||
|
||||
async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None: ...
|
||||
|
||||
@@ -69,6 +69,7 @@ class PullRequestInfo:
|
||||
head_repo: str = ""
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
head_sha: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -592,7 +593,7 @@ class GitHubClient:
|
||||
def _review_comments_payload(self, comments: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Adapt canonical host-tool comment shape to the wire schema for this platform.
|
||||
|
||||
GitHub keeps line/side/start_line/start_side; Forgejo/Gitea only reads
|
||||
GitHub keeps line/side/start_line/start_side; Forgejo only reads
|
||||
path/body/new_position (+old_position), so github-only keys are dropped
|
||||
and `line` is mapped to `new_position` for RIGHT-side comments or
|
||||
`old_position` for LEFT-side (removed-line) comments.
|
||||
@@ -798,6 +799,7 @@ def _pr_from_payload(repo: str, data: Mapping[str, Any]) -> PullRequestInfo:
|
||||
head_repo=str(head_repo.get("full_name") or "") if isinstance(head_repo, Mapping) else "",
|
||||
title=str(data.get("title") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
head_sha=str(head.get("sha") or "") if isinstance(head, Mapping) else "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1760,9 +1760,15 @@ def _diff_anchorable_lines(patch: str) -> tuple[frozenset[int], frozenset[int]]:
|
||||
old_line = int(m.group(1))
|
||||
new_line = int(m.group(2))
|
||||
continue
|
||||
if raw.startswith("+++") or raw.startswith("---") or raw.startswith("\\"):
|
||||
if raw.startswith("\\"):
|
||||
continue
|
||||
if new_line is None or old_line is None:
|
||||
# `+++`/`---` are file headers only before the first hunk. Inside a
|
||||
# hunk a diff line's *content* may start with `++` (added) or `--`
|
||||
# (removed), and those lines must advance the counters.
|
||||
in_hunk = new_line is not None and old_line is not None
|
||||
if raw.startswith(("+++", "---")) and not in_hunk:
|
||||
continue
|
||||
if not in_hunk:
|
||||
continue
|
||||
if raw.startswith("+"):
|
||||
right.add(new_line)
|
||||
@@ -1778,9 +1784,7 @@ def _diff_anchorable_lines(patch: str) -> tuple[frozenset[int], frozenset[int]]:
|
||||
return frozenset(right), frozenset(left)
|
||||
|
||||
|
||||
def _filter_anchorable_comments(
|
||||
staged: list[Any], files: list[PullRequestFileInfo]
|
||||
) -> tuple[list[Any], list[Any]]:
|
||||
def _filter_anchorable_comments(staged: list[Any], files: list[PullRequestFileInfo]) -> tuple[list[Any], list[Any]]:
|
||||
"""Partition staged comments into (anchorable, dropped) by diff hunk membership."""
|
||||
by_path = {f.path: f for f in files}
|
||||
cache: dict[str, tuple[frozenset[int], frozenset[int]]] = {}
|
||||
|
||||
@@ -671,6 +671,8 @@ def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
body = _require_str(data.get("body"), "body")
|
||||
event = str(data.get("event") or "COMMENT")
|
||||
comments = _require_review_comments(data.get("comments"))
|
||||
commit_id_raw = data.get("commit_id")
|
||||
commit_id = commit_id_raw if isinstance(commit_id_raw, str) and commit_id_raw else None
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
review = await github.submit_pr_review(
|
||||
@@ -679,6 +681,7 @@ def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
body=body,
|
||||
event=event,
|
||||
comments=comments,
|
||||
commit_id=commit_id,
|
||||
)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
|
||||
@@ -334,17 +334,21 @@ class GitHubProxyClient:
|
||||
body: str,
|
||||
event: str,
|
||||
comments: list[Mapping[str, Any]],
|
||||
commit_id: str | None = None,
|
||||
) -> PullRequestReviewInfo:
|
||||
json_body: dict[str, Any] = {
|
||||
"repo": repo,
|
||||
"pr_number": pr_number,
|
||||
"body": body,
|
||||
"event": event,
|
||||
"comments": comments,
|
||||
}
|
||||
if commit_id:
|
||||
json_body["commit_id"] = commit_id
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/gh/v1/submit_pr_review",
|
||||
json_body={
|
||||
"repo": repo,
|
||||
"pr_number": pr_number,
|
||||
"body": body,
|
||||
"event": event,
|
||||
"comments": comments,
|
||||
},
|
||||
json_body=json_body,
|
||||
)
|
||||
return _pr_review_from(data)
|
||||
|
||||
@@ -602,6 +606,7 @@ def _pr_file_from(data: Any) -> PullRequestFileInfo:
|
||||
status=str(data.get("status") or ""),
|
||||
additions=int(data.get("additions") or 0),
|
||||
deletions=int(data.get("deletions") or 0),
|
||||
patch=str(data.get("patch") or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -619,6 +624,7 @@ def _pr_from(data: Any) -> PullRequestInfo:
|
||||
head_repo=str(data.get("head_repo") or ""),
|
||||
title=str(data.get("title") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
head_sha=str(data.get("head_sha") or ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -124,7 +124,11 @@ def test_get_pull_request_parses_head_repo_and_author() -> None:
|
||||
json={
|
||||
"number": 9,
|
||||
"html_url": "https://github.com/octo/widget/pull/9",
|
||||
"head": {"ref": "farm/abc12345/fix", "repo": {"full_name": "octo/widget"}},
|
||||
"head": {
|
||||
"ref": "farm/abc12345/fix",
|
||||
"sha": "abc1234567890123456789012345678901234567",
|
||||
"repo": {"full_name": "octo/widget"},
|
||||
},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "robomp-bot"},
|
||||
@@ -134,6 +138,7 @@ def test_get_pull_request_parses_head_repo_and_author() -> None:
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
pr = _run_async(client.get_pull_request("octo/widget", 9))
|
||||
assert pr.head_ref == "farm/abc12345/fix"
|
||||
assert pr.head_sha == "abc1234567890123456789012345678901234567"
|
||||
assert pr.head_repo == "octo/widget"
|
||||
assert pr.author == "robomp-bot"
|
||||
|
||||
|
||||
@@ -64,15 +64,7 @@ def _stub_repo() -> RepoInfo:
|
||||
|
||||
# Unified diff whose anchorable lines are RIGHT {10..14} / LEFT {9..12};
|
||||
# line 15+ is a gap (unanchorable) and RIGHT line 9 is before the hunk.
|
||||
_PATCH = (
|
||||
"@@ -9,5 +10,6 @@ def f():\n"
|
||||
" ctx1\n"
|
||||
"-old10\n"
|
||||
"+new11\n"
|
||||
" ctx2\n"
|
||||
"+new13\n"
|
||||
" ctx3\n"
|
||||
)
|
||||
_PATCH = "@@ -9,5 +10,6 @@ def f():\n ctx1\n-old10\n+new11\n ctx2\n+new13\n ctx3\n"
|
||||
|
||||
|
||||
def _pr_files_response(request: httpx.Request, patch: str = _PATCH, *, status: int = 200) -> httpx.Response:
|
||||
@@ -1134,9 +1126,13 @@ def _pr_bindings(
|
||||
|
||||
|
||||
def _review_bindings(
|
||||
db: Database, tmp_path: Path, transport: httpx.MockTransport
|
||||
db: Database,
|
||||
tmp_path: Path,
|
||||
transport: httpx.MockTransport,
|
||||
*,
|
||||
platform: str = "github",
|
||||
) -> tuple[ToolBindings, asyncio.AbstractEventLoop, threading.Thread]:
|
||||
github = GitHubClient("token", transport=transport)
|
||||
github = GitHubClient("token", transport=transport, platform=platform)
|
||||
loop, thread = _make_loop_in_background()
|
||||
issue = IssueInfo(
|
||||
repo="octo/widget",
|
||||
@@ -1498,9 +1494,7 @@ def test_submit_pr_review_skips_validation_when_files_fetch_fails(db: Database,
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert captured["body"]["comments"] == [
|
||||
{"path": "src/app.py", "line": 15, "side": "RIGHT", "body": "gap finding"}
|
||||
]
|
||||
assert captured["body"]["comments"] == [{"path": "src/app.py", "line": 15, "side": "RIGHT", "body": "gap finding"}]
|
||||
assert captured["body"]["body"] == "summary"
|
||||
assert "comments=1" in result
|
||||
assert "dropped" not in result
|
||||
@@ -1646,6 +1640,204 @@ def test_submit_pr_review_range_requires_both_endpoints_anchorable(db: Database,
|
||||
assert "dropped=2" in result
|
||||
|
||||
|
||||
def test_submit_pr_review_forgejo_fetches_commit_id(db: Database, tmp_path: Path) -> None:
|
||||
"""Forgejo anchors inline comments by commit: submit must fetch the PR and
|
||||
include the head sha as commit_id in the reviews POST body."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/octo/widget/pulls/99":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 99,
|
||||
"html_url": "https://x/octo/widget/pull/99",
|
||||
"head": {"ref": "fix-crash", "sha": "abc123456789"},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
},
|
||||
)
|
||||
if request.url.path == "/repos/octo/widget/pulls/99/files":
|
||||
return _pr_files_response(request)
|
||||
if request.url.path.endswith("/reviews"):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 44,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "ok",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler), platform="forgejo")
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
stage_tool.execute({"path": "src/app.py", "line": 12, "body": "in-hunk finding"}, _ctx())
|
||||
result = submit_tool.execute({"body": "summary"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "submitted PR review" in result
|
||||
assert captured["body"]["commit_id"] == "abc123456789"
|
||||
|
||||
|
||||
def test_submit_pr_review_forgejo_commit_id_fetch_failure_is_swallowed(
|
||||
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When the PR fetch fails the commit_id is omitted (fail open) and the
|
||||
review still submits without it."""
|
||||
monkeypatch.setattr(GitHubClient, "_TRANSIENT_RETRY_DELAYS", (0.01, 0.01))
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/octo/widget/pulls/99":
|
||||
return httpx.Response(500, json={"message": "internal error"})
|
||||
if request.url.path == "/repos/octo/widget/pulls/99/files":
|
||||
return _pr_files_response(request)
|
||||
if request.url.path.endswith("/reviews"):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 44,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "ok",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler), platform="forgejo")
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
stage_tool.execute({"path": "src/app.py", "line": 12, "body": "in-hunk finding"}, _ctx())
|
||||
result = submit_tool.execute({"body": "summary"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "submitted PR review" in result
|
||||
assert "commit_id" not in captured["body"]
|
||||
|
||||
|
||||
def test_submit_pr_review_422_and_fallback_comment_failure_raises_and_keeps_staged(
|
||||
db: Database,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When the reviews endpoint AND the issue-comments fallback both fail, the
|
||||
tool raises and the staged comments survive for a later retry."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/octo/widget/pulls/99/files":
|
||||
return _pr_files_response(request)
|
||||
if request.url.path.endswith("/reviews"):
|
||||
return httpx.Response(422, json={"message": "Validation failed"})
|
||||
if request.url.path == "/repos/octo/widget/issues/99/comments":
|
||||
return httpx.Response(500, json={"message": "internal error"})
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
stage_tool.execute({"path": "src/app.py", "line": 12, "body": "finding"}, _ctx())
|
||||
with pytest.raises(RpcCommandError, match="fallback comment posting failed"):
|
||||
submit_tool.execute({"body": "summary"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
rows = db.list_staged_review_comments(bindings.issue_key)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].path == "src/app.py"
|
||||
|
||||
|
||||
def test_submit_pr_review_empty_patch_fails_open(db: Database, tmp_path: Path) -> None:
|
||||
"""A file whose patch the platform omitted is not a rejection reason: the
|
||||
comment is kept rather than folded into a 'Not anchored to diff' section."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/octo/widget/pulls/99/files":
|
||||
return _pr_files_response(request, patch="")
|
||||
if request.url.path.endswith("/reviews"):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 44,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "ok",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
stage_tool.execute({"path": "src/app.py", "line": 12, "body": "binary finding"}, _ctx())
|
||||
result = submit_tool.execute({"body": "summary"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "submitted PR review" in result
|
||||
assert "dropped" not in result
|
||||
assert captured["body"]["comments"] == [
|
||||
{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "binary finding"}
|
||||
]
|
||||
assert "Not anchored to diff" not in captured["body"]["body"]
|
||||
|
||||
|
||||
def test_submit_pr_review_left_side_single_line_anchoring(db: Database, tmp_path: Path) -> None:
|
||||
"""LEFT-side comments anchor against the old file's hunk lines
|
||||
(_PATCH: LEFT {9..12}): an in-hunk line is kept, an out-of-hunk line is
|
||||
dropped and folded into the summary."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/octo/widget/pulls/99/files":
|
||||
return _pr_files_response(request)
|
||||
if request.url.path.endswith("/reviews"):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 44,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "ok",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
bindings, loop, t = _review_bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
stage_tool = next(x for x in build(bindings) if x.name == "pr_review_comment")
|
||||
submit_tool = next(x for x in build(bindings) if x.name == "submit_pr_review")
|
||||
stage_tool.execute({"path": "src/app.py", "line": 10, "side": "LEFT", "body": "old line finding"}, _ctx())
|
||||
stage_tool.execute({"path": "src/app.py", "line": 13, "side": "LEFT", "body": "gap finding"}, _ctx())
|
||||
result = submit_tool.execute({"body": "summary"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert "dropped=1" in result
|
||||
assert captured["body"]["comments"] == [
|
||||
{"path": "src/app.py", "line": 10, "side": "LEFT", "body": "old line finding"}
|
||||
]
|
||||
assert "## Not anchored to diff" in captured["body"]["body"]
|
||||
assert "**`src/app.py:13`** — gap finding" in captured["body"]["body"]
|
||||
|
||||
|
||||
def test_diff_anchorable_lines_parses_hunk_sides() -> None:
|
||||
right, left = host_tools._diff_anchorable_lines(_PATCH)
|
||||
assert right == frozenset({10, 11, 12, 13, 14})
|
||||
@@ -1653,9 +1845,7 @@ def test_diff_anchorable_lines_parses_hunk_sides() -> None:
|
||||
|
||||
# Gap between hunks: line 30 in the old file / 29..30 in the new file are
|
||||
# unanchorable (the PR 1111 smtp.go:106 failure shape).
|
||||
right, left = host_tools._diff_anchorable_lines(
|
||||
"@@ -1,2 +1,3 @@\n a\n+x\n b\n@@ -30,2 +31,2 @@\n c\n d\n"
|
||||
)
|
||||
right, left = host_tools._diff_anchorable_lines("@@ -1,2 +1,3 @@\n a\n+x\n b\n@@ -30,2 +31,2 @@\n c\n d\n")
|
||||
assert right >= frozenset({1, 2, 3, 31, 32})
|
||||
assert right.isdisjoint(range(4, 31))
|
||||
|
||||
@@ -1667,11 +1857,30 @@ def test_diff_anchorable_lines_parses_hunk_sides() -> None:
|
||||
assert left == frozenset({9})
|
||||
|
||||
# `\ No newline at end of file` markers are ignored.
|
||||
right, _ = host_tools._diff_anchorable_lines(
|
||||
"@@ -1,2 +1,3 @@\n a\n+b\n\\ No newline at end of file\n"
|
||||
)
|
||||
right, _ = host_tools._diff_anchorable_lines("@@ -1,2 +1,3 @@\n a\n+b\n\\ No newline at end of file\n")
|
||||
assert right == frozenset({1, 2})
|
||||
|
||||
# File-creation hunk boundary: everything is added, nothing exists on the
|
||||
# LEFT; file-deletion hunk: everything is removed, RIGHT is empty.
|
||||
right, left = host_tools._diff_anchorable_lines("@@ -0,0 +1,2 @@\n+a\n+b\n")
|
||||
assert right == frozenset({1, 2})
|
||||
assert left == frozenset()
|
||||
|
||||
right, left = host_tools._diff_anchorable_lines("@@ -5,3 +5,0 @@\n-a\n-b\n-c\n")
|
||||
assert right == frozenset()
|
||||
assert left == frozenset({5, 6, 7})
|
||||
|
||||
|
||||
def test_diff_anchorable_lines_in_hunk_plus_minus_content() -> None:
|
||||
"""Added lines whose content starts with `++` and removed lines starting
|
||||
with `--` must be treated as diff content, not file headers — otherwise
|
||||
the line counters desync (the `+++ b/...` skip used to swallow them)."""
|
||||
patch = "+++ b/src/app.py\n--- a/src/app.py\n@@ -1,2 +1,3 @@\n ctx\n tail\n--- removed\n+++ added\n"
|
||||
# Old file: ctx(1), tail(2), --- removed(3). New file: ctx(1), tail(2), +++ added(3).
|
||||
right, left = host_tools._diff_anchorable_lines(patch)
|
||||
assert right == frozenset({1, 2, 3})
|
||||
assert left == frozenset({1, 2, 3})
|
||||
|
||||
|
||||
def test_review_tools_reject_outside_review_mode(db: Database, tmp_path: Path) -> None:
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
|
||||
|
||||
@@ -31,7 +31,7 @@ from robomp.github_client import (
|
||||
)
|
||||
from robomp.proxy.server import create_proxy_app
|
||||
from robomp.proxy_client import GitHubProxyClient, ProxyGitTransport
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, verify
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, sign, verify
|
||||
from robomp.sandbox import workspace_key
|
||||
|
||||
_HMAC = "test-hmac-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
@@ -316,7 +316,15 @@ def round_trip_app(proxy_settings: Settings):
|
||||
if path == "/repos/octo/widget/pulls/2/files":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"filename": "src/app.py", "status": "modified", "additions": 2, "deletions": 1}],
|
||||
json=[
|
||||
{
|
||||
"filename": "src/app.py",
|
||||
"status": "modified",
|
||||
"additions": 2,
|
||||
"deletions": 1,
|
||||
"patch": "@@ -8,5 +8,6 @@\n ctx\n-old\n+new\n",
|
||||
}
|
||||
],
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/2/reviews" and req.method == "POST":
|
||||
body = json.loads(req.content)
|
||||
@@ -340,7 +348,11 @@ def round_trip_app(proxy_settings: Settings):
|
||||
json={
|
||||
"number": 4,
|
||||
"html_url": "https://example/4",
|
||||
"head": {"ref": "feat", "repo": {"full_name": "octo/widget"}},
|
||||
"head": {
|
||||
"ref": "feat",
|
||||
"sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"repo": {"full_name": "octo/widget"},
|
||||
},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "robomp-bot"},
|
||||
@@ -405,6 +417,7 @@ async def test_round_trip_all_endpoints(round_trip_app) -> None:
|
||||
files = await client.list_pr_files("octo/widget", 2)
|
||||
assert len(files) == 1 and isinstance(files[0], PullRequestFileInfo)
|
||||
assert files[0].path == "src/app.py"
|
||||
assert files[0].patch == "@@ -8,5 +8,6 @@\n ctx\n-old\n+new\n"
|
||||
|
||||
submitted = await client.submit_pr_review(
|
||||
repo="octo/widget",
|
||||
@@ -420,6 +433,7 @@ async def test_round_trip_all_endpoints(round_trip_app) -> None:
|
||||
existing_pr = await client.get_pull_request("octo/widget", 4)
|
||||
assert isinstance(existing_pr, PullRequestInfo)
|
||||
assert existing_pr.head_ref == "feat"
|
||||
assert existing_pr.head_sha == "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||
assert existing_pr.author == "robomp-bot"
|
||||
|
||||
posted = await client.post_comment("octo/widget", 1, "hi")
|
||||
@@ -486,6 +500,144 @@ async def test_close_issue_round_trip(proxy_settings: Settings) -> None:
|
||||
assert captured["body"] == {"state": "closed", "state_reason": "completed"}
|
||||
|
||||
|
||||
def _capturing_app(app, path: str) -> tuple[Callable, list[dict[str, object]]]:
|
||||
"""ASGI wrapper recording raw JSON bodies POSTed to `path`."""
|
||||
bodies: list[dict[str, object]] = []
|
||||
|
||||
async def middleware(scope, receive, send):
|
||||
if scope["type"] == "http" and scope.get("path") == path:
|
||||
raw = b""
|
||||
while True:
|
||||
message = await receive()
|
||||
raw += message.get("body", b"")
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
bodies.append(json.loads(raw))
|
||||
|
||||
async def replay() -> dict[str, object]:
|
||||
return {"type": "http.request", "body": raw, "more_body": False}
|
||||
|
||||
return await app(scope, replay, send)
|
||||
return await app(scope, receive, send)
|
||||
|
||||
return middleware, bodies
|
||||
|
||||
|
||||
async def test_submit_pr_review_commit_id_reaches_wire(proxy_settings: Settings) -> None:
|
||||
"""commit_id must appear in the /gh/v1/submit_pr_review wire body the
|
||||
proxy client POSTs, and the server must forward it to the direct client."""
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
upstream: dict[str, object] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/repos/octo/widget/pulls/2/reviews" and req.method == "POST":
|
||||
upstream["body"] = json.loads(req.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 55,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "summary",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
_attach_gh(app, gh)
|
||||
middleware, wire_bodies = _capturing_app(app, "/gh/v1/submit_pr_review")
|
||||
client = GitHubProxyClient(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.ASGITransport(app=middleware),
|
||||
)
|
||||
review = await client.submit_pr_review(
|
||||
repo="octo/widget",
|
||||
pr_number=2,
|
||||
body="summary",
|
||||
event="COMMENT",
|
||||
comments=[{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}],
|
||||
commit_id="abc123",
|
||||
)
|
||||
assert review.id == 55
|
||||
assert wire_bodies == [
|
||||
{
|
||||
"repo": "octo/widget",
|
||||
"pr_number": 2,
|
||||
"body": "summary",
|
||||
"event": "COMMENT",
|
||||
"comments": [{"path": "src/app.py", "line": 12, "side": "RIGHT", "body": "finding"}],
|
||||
"commit_id": "abc123",
|
||||
}
|
||||
]
|
||||
# Server forwarded it to the direct client, which put it on the GitHub wire.
|
||||
assert upstream["body"]["commit_id"] == "abc123"
|
||||
|
||||
# Without commit_id the key is omitted from the proxy wire body.
|
||||
await client.submit_pr_review(
|
||||
repo="octo/widget",
|
||||
pr_number=2,
|
||||
body="summary",
|
||||
event="COMMENT",
|
||||
comments=[],
|
||||
)
|
||||
assert "commit_id" not in wire_bodies[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_commit_id", [12345, ""])
|
||||
async def test_submit_pr_review_rejects_non_string_commit_id(proxy_settings: Settings, bad_commit_id: object) -> None:
|
||||
"""The proxy server must not forward a non-string or empty commit_id
|
||||
upstream: a raw POST bypasses the proxy client, so assert on the
|
||||
upstream-captured reviews body — the key must be absent and the request
|
||||
still succeeds."""
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
upstream: dict[str, object] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/repos/octo/widget/pulls/3/reviews" and req.method == "POST":
|
||||
upstream["body"] = json.loads(req.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 56,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "summary",
|
||||
"state": "COMMENTED",
|
||||
"submitted_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
app.state.github = GitHubClient(_TOKEN, transport=httpx.MockTransport(gh))
|
||||
payload = {
|
||||
"repo": "octo/widget",
|
||||
"pr_number": 3,
|
||||
"body": "summary",
|
||||
"event": "COMMENT",
|
||||
"comments": [],
|
||||
"commit_id": bad_commit_id,
|
||||
}
|
||||
body = json.dumps(payload).encode()
|
||||
timestamp, sig = sign(method="POST", path="/gh/v1/submit_pr_review", body=body, key=_HMAC_BYTES)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://proxy.test",
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/submit_pr_review",
|
||||
content=body,
|
||||
headers={
|
||||
HEADER_TIMESTAMP: timestamp,
|
||||
HEADER_SIGNATURE: sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "commit_id" not in upstream["body"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. Error decode
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user