mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 10:12:10 +00:00
fix: reviewer publishes against stale head_sha on mid-run re-review (#1393)
* fix: resolve reviewer head_sha from thread metadata, not frozen run config A push that lands while a reviewer run is in flight is delivered as a queued message into that run. The run's configurable is frozen at creation, so its head_sha still names the commit the run was created for — not the commit just pushed. publish_review then anchored the GitHub review to the stale commit and regressed last_reviewed_sha to it, and add_finding/update_finding stamped findings with the stale SHA. Persist the current head in thread metadata at every reviewer dispatch (both the ready-for-review and push paths, before they branch to create a run or queue a message), and add resolve_review_head_sha() which prefers the metadata head over the run config. Wire it into publish_review (review commit_id + last_reviewed_sha), add_finding (first_seen_sha) and update_finding (last_confirmed_sha). Falls back to the run config when metadata carries no head (first review, eval, tests). * fix: persist head_sha in manual review dispatch (trigger_pr_review_from_ref) resolve_review_head_sha prefers metadata[head_sha] over the run config, and the push/ready dispatchers write it — but trigger_pr_review_from_ref (Slack/GitHub @open-swe review, request_pr_review tool) created a run with a freshly-fetched config head while leaving metadata's head stale from a prior dispatch. A manual re-review at a newer commit would then resolve to the old head and publish/advance findings against it. Persist head_sha in that dispatch's metadata write too, so every run-creating reviewer dispatch keeps metadata in sync with the head its run targets. Caught by the Open SWE reviewer on this PR.
This commit is contained in:
parent
94c44a9642
commit
1d4f1aed33
11 changed files with 219 additions and 12 deletions
|
|
@ -294,6 +294,24 @@ async def get_thread_metadata(thread_id: str) -> dict[str, Any]:
|
||||||
return metadata if isinstance(metadata, dict) else {}
|
return metadata if isinstance(metadata, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_review_head_sha(thread_id: str, configurable: dict[str, Any]) -> str:
|
||||||
|
"""Return the current PR head SHA for a reviewer run.
|
||||||
|
|
||||||
|
A push that lands while a reviewer run is in flight is delivered as a queued
|
||||||
|
message into that run, whose frozen ``configurable`` still names the head the
|
||||||
|
run was created for. The dispatching webhook records the current head in
|
||||||
|
thread metadata, so prefer that; fall back to the run's config when metadata
|
||||||
|
carries no head (first review, eval, tests).
|
||||||
|
"""
|
||||||
|
config_head = configurable.get("head_sha") if isinstance(configurable, dict) else None
|
||||||
|
config_head = config_head if isinstance(config_head, str) else ""
|
||||||
|
if not thread_id:
|
||||||
|
return config_head
|
||||||
|
metadata = await get_thread_metadata(thread_id)
|
||||||
|
meta_head = metadata.get("head_sha")
|
||||||
|
return meta_head if isinstance(meta_head, str) and meta_head else config_head
|
||||||
|
|
||||||
|
|
||||||
async def list_findings(thread_id: str) -> list[Finding]:
|
async def list_findings(thread_id: str) -> list[Finding]:
|
||||||
"""Return all findings persisted on the reviewer thread."""
|
"""Return all findings persisted on the reviewer thread."""
|
||||||
metadata = await get_thread_metadata(thread_id)
|
metadata = await get_thread_metadata(thread_id)
|
||||||
|
|
@ -438,6 +456,7 @@ async def set_reviewer_thread_metadata(
|
||||||
*,
|
*,
|
||||||
pr: ReviewerPRMeta | None = None,
|
pr: ReviewerPRMeta | None = None,
|
||||||
last_reviewed_sha: str | None = None,
|
last_reviewed_sha: str | None = None,
|
||||||
|
head_sha: str | None = None,
|
||||||
watch: bool | None = None,
|
watch: bool | None = None,
|
||||||
findings: list[Finding] | None = None,
|
findings: list[Finding] | None = None,
|
||||||
slack_thread: ReviewerSlackThread | None = None,
|
slack_thread: ReviewerSlackThread | None = None,
|
||||||
|
|
@ -448,6 +467,11 @@ async def set_reviewer_thread_metadata(
|
||||||
Always sets ``kind=reviewer`` so the future UI can list reviewer threads by
|
Always sets ``kind=reviewer`` so the future UI can list reviewer threads by
|
||||||
filtering on metadata. Only includes the fields the caller passed in
|
filtering on metadata. Only includes the fields the caller passed in
|
||||||
(langgraph metadata updates merge rather than overwrite).
|
(langgraph metadata updates merge rather than overwrite).
|
||||||
|
|
||||||
|
``head_sha`` records the current PR head the dispatching webhook is acting
|
||||||
|
on. A push that lands mid-run is queued into the still-running run, whose
|
||||||
|
frozen config can't be updated; persisting the head here lets the reviewer
|
||||||
|
tools resolve the live head via ``resolve_review_head_sha``.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
client = get_client()
|
||||||
metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
|
metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
|
||||||
|
|
@ -455,6 +479,8 @@ async def set_reviewer_thread_metadata(
|
||||||
metadata["pr"] = pr
|
metadata["pr"] = pr
|
||||||
if last_reviewed_sha is not None:
|
if last_reviewed_sha is not None:
|
||||||
metadata["last_reviewed_sha"] = last_reviewed_sha
|
metadata["last_reviewed_sha"] = last_reviewed_sha
|
||||||
|
if head_sha is not None:
|
||||||
|
metadata["head_sha"] = head_sha
|
||||||
if watch is not None:
|
if watch is not None:
|
||||||
metadata["watch"] = watch
|
metadata["watch"] = watch
|
||||||
if findings is not None:
|
if findings is not None:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from ..reviewer_findings import (
|
||||||
get_thread_id_from_runtime,
|
get_thread_id_from_runtime,
|
||||||
new_finding,
|
new_finding,
|
||||||
normalize_finding_title,
|
normalize_finding_title,
|
||||||
|
resolve_review_head_sha,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,7 +113,6 @@ def add_finding(
|
||||||
config = get_config()
|
config = get_config()
|
||||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||||
diff_line_set = configurable.get("diff_line_set") if isinstance(configurable, dict) else None
|
diff_line_set = configurable.get("diff_line_set") if isinstance(configurable, dict) else None
|
||||||
head_sha = configurable.get("head_sha", "") if isinstance(configurable, dict) else ""
|
|
||||||
diff_text = configurable.get("diff_text", "") if isinstance(configurable, dict) else ""
|
diff_text = configurable.get("diff_text", "") if isinstance(configurable, dict) else ""
|
||||||
|
|
||||||
if isinstance(diff_line_set, dict) and not is_range_in_diff(
|
if isinstance(diff_line_set, dict) and not is_range_in_diff(
|
||||||
|
|
@ -134,6 +134,9 @@ def add_finding(
|
||||||
|
|
||||||
clipped_suggestion, suggestion_dropped = clip_suggestion(suggestion)
|
clipped_suggestion, suggestion_dropped = clip_suggestion(suggestion)
|
||||||
|
|
||||||
|
thread_id = get_thread_id_from_runtime()
|
||||||
|
head_sha = asyncio.run(resolve_review_head_sha(thread_id, configurable))
|
||||||
|
|
||||||
finding: Finding = new_finding(
|
finding: Finding = new_finding(
|
||||||
severity=_cast_severity(severity),
|
severity=_cast_severity(severity),
|
||||||
confidence=_cast_confidence(confidence),
|
confidence=_cast_confidence(confidence),
|
||||||
|
|
@ -142,14 +145,13 @@ def add_finding(
|
||||||
start_line=start_line,
|
start_line=start_line,
|
||||||
end_line=end_line,
|
end_line=end_line,
|
||||||
description=description,
|
description=description,
|
||||||
sha=str(head_sha) if isinstance(head_sha, str) else "",
|
sha=head_sha,
|
||||||
title=normalized_title,
|
title=normalized_title,
|
||||||
side=_cast_side(side),
|
side=_cast_side(side),
|
||||||
suggestion=clipped_suggestion,
|
suggestion=clipped_suggestion,
|
||||||
diff_hunk=diff_hunk,
|
diff_hunk=diff_hunk,
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_id = get_thread_id_from_runtime()
|
|
||||||
asyncio.run(append_finding(thread_id, finding))
|
asyncio.run(append_finding(thread_id, finding))
|
||||||
result: dict[str, Any] = {"success": True, "finding_id": finding["id"]}
|
result: dict[str, Any] = {"success": True, "finding_id": finding["id"]}
|
||||||
if suggestion_dropped:
|
if suggestion_dropped:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from ..reviewer_findings import (
|
||||||
get_thread_metadata,
|
get_thread_metadata,
|
||||||
get_thread_slack_ref,
|
get_thread_slack_ref,
|
||||||
replace_findings,
|
replace_findings,
|
||||||
|
resolve_review_head_sha,
|
||||||
set_reviewer_thread_metadata,
|
set_reviewer_thread_metadata,
|
||||||
)
|
)
|
||||||
from ..reviewer_findings import (
|
from ..reviewer_findings import (
|
||||||
|
|
@ -202,6 +203,11 @@ async def _publish_review_async(
|
||||||
trace_link_config_override: object = None,
|
trace_link_config_override: object = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
thread_id = get_thread_id_from_runtime()
|
thread_id = get_thread_id_from_runtime()
|
||||||
|
# The run config's head_sha is frozen at run creation; a push that arrived
|
||||||
|
# mid-run updated the live head in thread metadata. Prefer that so the
|
||||||
|
# review anchors to (and last_reviewed_sha advances to) the commit actually
|
||||||
|
# reviewed, not the stale one this run was created for.
|
||||||
|
head_sha = await resolve_review_head_sha(thread_id, {"head_sha": head_sha})
|
||||||
review_trace_url = await _resolve_review_trace_url(thread_id, trace_link_config_override)
|
review_trace_url = await _resolve_review_trace_url(thread_id, trace_link_config_override)
|
||||||
findings = await _backfill_findings_from_pr_threads(
|
findings = await _backfill_findings_from_pr_threads(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from ..reviewer_findings import (
|
||||||
get_thread_id_from_runtime,
|
get_thread_id_from_runtime,
|
||||||
list_findings,
|
list_findings,
|
||||||
normalize_finding_title,
|
normalize_finding_title,
|
||||||
|
resolve_review_head_sha,
|
||||||
update_finding_fields,
|
update_finding_fields,
|
||||||
)
|
)
|
||||||
from ..utils.reviewer_outcomes import emit_finding_status_outcome
|
from ..utils.reviewer_outcomes import emit_finding_status_outcome
|
||||||
|
|
@ -127,9 +128,10 @@ def update_finding(
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||||
head_sha = configurable.get("head_sha", "") if isinstance(configurable, dict) else ""
|
if status == "open":
|
||||||
if status == "open" and isinstance(head_sha, str) and head_sha:
|
head_sha = asyncio.run(resolve_review_head_sha(get_thread_id_from_runtime(), configurable))
|
||||||
updates["last_confirmed_sha"] = head_sha
|
if head_sha:
|
||||||
|
updates["last_confirmed_sha"] = head_sha
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
if suggestion_dropped:
|
if suggestion_dropped:
|
||||||
|
|
|
||||||
|
|
@ -1781,7 +1781,7 @@ async def trigger_pr_review_from_ref(
|
||||||
"thread_ts": slack_thread_ts,
|
"thread_ts": slack_thread_ts,
|
||||||
}
|
}
|
||||||
await set_reviewer_thread_metadata(
|
await set_reviewer_thread_metadata(
|
||||||
thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta
|
thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta, head_sha=head_sha
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha)
|
prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha)
|
||||||
|
|
@ -1959,7 +1959,7 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
|
||||||
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
|
await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
||||||
|
|
||||||
is_re_review = bool(last_reviewed_sha)
|
is_re_review = bool(last_reviewed_sha)
|
||||||
if is_re_review:
|
if is_re_review:
|
||||||
|
|
@ -2400,7 +2400,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
|
||||||
"head_ref": head_ref,
|
"head_ref": head_ref,
|
||||||
"base_ref": base_ref,
|
"base_ref": base_ref,
|
||||||
}
|
}
|
||||||
await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True)
|
await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
|
||||||
|
|
||||||
re_review_prompt = (
|
re_review_prompt = (
|
||||||
f"A new commit has been pushed to PR #{pr_number}. The new HEAD is "
|
f"A new commit has been pushed to PR #{pr_number}. The new HEAD is "
|
||||||
|
|
|
||||||
|
|
@ -911,8 +911,9 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
threads = _FakeThreadsClient()
|
threads = _FakeThreadsClient()
|
||||||
|
|
||||||
async def fake_set_reviewer_thread_metadata(thread_id: str, **_kwargs: object) -> None:
|
async def fake_set_reviewer_thread_metadata(thread_id: str, **kwargs: object) -> None:
|
||||||
captured["set_metadata_thread_id"] = thread_id
|
captured["set_metadata_thread_id"] = thread_id
|
||||||
|
captured["set_metadata_kwargs"] = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
|
|
@ -1011,8 +1012,9 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
runs = _FakeRunsClient()
|
runs = _FakeRunsClient()
|
||||||
threads = _FakeThreadsClient()
|
threads = _FakeThreadsClient()
|
||||||
|
|
||||||
async def fake_set_reviewer_thread_metadata(thread_id: str, **_kwargs: object) -> None:
|
async def fake_set_reviewer_thread_metadata(thread_id: str, **kwargs: object) -> None:
|
||||||
captured["set_metadata_thread_id"] = thread_id
|
captured["set_metadata_thread_id"] = thread_id
|
||||||
|
captured["set_metadata_kwargs"] = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
|
||||||
|
|
@ -1065,6 +1067,9 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
||||||
"channel_id": "C123",
|
"channel_id": "C123",
|
||||||
"thread_ts": "1700000000.000100",
|
"thread_ts": "1700000000.000100",
|
||||||
}
|
}
|
||||||
|
# The live head must be persisted to metadata so resolve_review_head_sha
|
||||||
|
# doesn't return a stale head left by a prior push/ready dispatch.
|
||||||
|
assert captured["set_metadata_kwargs"]["head_sha"] == "head-sha"
|
||||||
|
|
||||||
|
|
||||||
def test_trigger_pr_review_from_ref_respects_dashboard_opt_in(monkeypatch) -> None:
|
def test_trigger_pr_review_from_ref_respects_dashboard_opt_in(monkeypatch) -> None:
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,12 @@ async def test_pr_ready_for_review_uses_re_review_after_previous_review(
|
||||||
assert configurable["last_reviewed_sha"] == "oldsha"
|
assert configurable["last_reviewed_sha"] == "oldsha"
|
||||||
assert configurable["head_sha"] == "headsha"
|
assert configurable["head_sha"] == "headsha"
|
||||||
assert "marked ready for review" in kwargs["input"]["messages"][0]["content"]
|
assert "marked ready for review" in kwargs["input"]["messages"][0]["content"]
|
||||||
|
head_sha_writes = [
|
||||||
|
c.kwargs.get("head_sha")
|
||||||
|
for c in webapp.set_reviewer_thread_metadata.await_args_list
|
||||||
|
if c.kwargs.get("head_sha") is not None
|
||||||
|
]
|
||||||
|
assert "headsha" in head_sha_writes
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from agent.reviewer_findings import (
|
||||||
new_finding,
|
new_finding,
|
||||||
new_finding_id,
|
new_finding_id,
|
||||||
replace_findings,
|
replace_findings,
|
||||||
|
resolve_review_head_sha,
|
||||||
set_reviewer_thread_metadata,
|
set_reviewer_thread_metadata,
|
||||||
update_finding_fields,
|
update_finding_fields,
|
||||||
)
|
)
|
||||||
|
|
@ -181,3 +182,41 @@ async def test_set_reviewer_thread_metadata_includes_kind() -> None:
|
||||||
assert metadata["last_reviewed_sha"] == "sha"
|
assert metadata["last_reviewed_sha"] == "sha"
|
||||||
assert "pr" not in metadata
|
assert "pr" not in metadata
|
||||||
assert "findings" not in metadata
|
assert "findings" not in metadata
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_reviewer_thread_metadata_persists_head_sha() -> None:
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||||
|
await set_reviewer_thread_metadata("tid", head_sha="newhead")
|
||||||
|
metadata = fake_client.threads.update.await_args.kwargs["metadata"]
|
||||||
|
assert metadata["head_sha"] == "newhead"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_review_head_sha_prefers_metadata_over_config() -> None:
|
||||||
|
"""A mid-run push records the live head in thread metadata; it must win over
|
||||||
|
the stale head frozen in the run's config."""
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.threads.get.return_value = {"metadata": {"head_sha": "metahead"}}
|
||||||
|
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||||
|
head = await resolve_review_head_sha("tid", {"head_sha": "confighead"})
|
||||||
|
assert head == "metahead"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_review_head_sha_falls_back_to_config_when_metadata_empty() -> None:
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.threads.get.return_value = {"metadata": {}}
|
||||||
|
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||||
|
head = await resolve_review_head_sha("tid", {"head_sha": "confighead"})
|
||||||
|
assert head == "confighead"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_review_head_sha_falls_back_without_thread_id() -> None:
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||||
|
head = await resolve_review_head_sha("", {"head_sha": "confighead"})
|
||||||
|
assert head == "confighead"
|
||||||
|
fake_client.threads.get.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ def _isolate_publish_review_pr_state() -> Iterator[None]:
|
||||||
patch("agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=[])),
|
patch("agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=[])),
|
||||||
patch("agent.tools.publish_review.replace_findings", AsyncMock()),
|
patch("agent.tools.publish_review.replace_findings", AsyncMock()),
|
||||||
patch("agent.tools.publish_review.open_swe_review_exists", AsyncMock(return_value=False)),
|
patch("agent.tools.publish_review.open_swe_review_exists", AsyncMock(return_value=False)),
|
||||||
|
patch(
|
||||||
|
"agent.tools.publish_review.resolve_review_head_sha",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=lambda thread_id, configurable: configurable.get("head_sha") or ""
|
||||||
|
),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
@ -562,6 +568,56 @@ async def test_publish_review_skips_duplicate_empty_summary_when_open_swe_alread
|
||||||
set_metadata.assert_awaited_once_with("tid", last_reviewed_sha="newsha")
|
set_metadata.assert_awaited_once_with("tid", last_reviewed_sha="newsha")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_review_uses_resolved_head_sha_for_commit_and_last_reviewed() -> None:
|
||||||
|
"""A push that landed mid-run updates the live head in thread metadata.
|
||||||
|
publish_review must anchor the GitHub review to that head and advance
|
||||||
|
last_reviewed_sha to it, not the stale head frozen in the run config."""
|
||||||
|
from agent.tools.publish_review import _publish_review_async
|
||||||
|
|
||||||
|
finding = _f(id="f_new", file="b.py", start_line=2, end_line=2)
|
||||||
|
post_review = AsyncMock(return_value={"id": 4242})
|
||||||
|
set_metadata = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
|
||||||
|
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[finding])),
|
||||||
|
patch(
|
||||||
|
"agent.tools.publish_review.resolve_review_head_sha",
|
||||||
|
AsyncMock(return_value="freshhead"),
|
||||||
|
),
|
||||||
|
patch("agent.tools.publish_review.post_pull_request_review", post_review),
|
||||||
|
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
|
||||||
|
patch(
|
||||||
|
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=0,
|
||||||
|
),
|
||||||
|
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
|
||||||
|
patch(
|
||||||
|
"agent.tools.publish_review._maybe_post_slack_completion_reply",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await _publish_review_async(
|
||||||
|
owner="o",
|
||||||
|
repo="r",
|
||||||
|
pr_number=7,
|
||||||
|
head_sha="stalehead",
|
||||||
|
token="t",
|
||||||
|
severity_threshold="medium",
|
||||||
|
cap=15,
|
||||||
|
is_re_review=False,
|
||||||
|
langgraph_run_id="run-x",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert post_review.await_args.kwargs["head_sha"] == "freshhead"
|
||||||
|
final = set_metadata.await_args_list[-1]
|
||||||
|
assert final.args[0] == "tid"
|
||||||
|
assert final.kwargs["last_reviewed_sha"] == "freshhead"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_publish_review_skips_review_existence_check_on_re_review() -> None:
|
async def test_publish_review_skips_review_existence_check_on_re_review() -> None:
|
||||||
"""When re_review is already True we know a prior review exists, so the
|
"""When re_review is already True we know a prior review exists, so the
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,38 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from agent.tools.add_finding import add_finding
|
from agent.tools.add_finding import add_finding
|
||||||
from agent.tools.list_findings import list_findings
|
from agent.tools.list_findings import list_findings
|
||||||
from agent.tools.resolve_finding_thread import resolve_finding_thread
|
from agent.tools.resolve_finding_thread import resolve_finding_thread
|
||||||
from agent.tools.update_finding import update_finding
|
from agent.tools.update_finding import update_finding
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stub_resolve_review_head_sha() -> Iterator[None]:
|
||||||
|
"""Resolve the review head from the run config (no thread-metadata fetch).
|
||||||
|
|
||||||
|
Mirrors the production fallback when metadata carries no head, keeping these
|
||||||
|
unit tests offline. Tests that exercise the metadata-override path patch
|
||||||
|
``resolve_review_head_sha`` themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _head(thread_id: str, configurable: dict[str, Any]) -> str:
|
||||||
|
head = configurable.get("head_sha")
|
||||||
|
return head if isinstance(head, str) else ""
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.tools.add_finding.resolve_review_head_sha", AsyncMock(side_effect=_head)),
|
||||||
|
patch("agent.tools.update_finding.resolve_review_head_sha", AsyncMock(side_effect=_head)),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def _config(**configurable_overrides: Any) -> dict[str, Any]:
|
def _config(**configurable_overrides: Any) -> dict[str, Any]:
|
||||||
base: dict[str, Any] = {
|
base: dict[str, Any] = {
|
||||||
"configurable": {
|
"configurable": {
|
||||||
|
|
@ -200,6 +223,40 @@ def test_add_finding_persists_to_thread_metadata() -> None:
|
||||||
assert persisted["confidence"] == "high"
|
assert persisted["confidence"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_finding_uses_resolved_head_sha_for_provenance() -> None:
|
||||||
|
"""A net-new finding filed during a mid-run re-review must record the live
|
||||||
|
head (from thread metadata), not the stale head frozen in the run config."""
|
||||||
|
captured: list[Any] = []
|
||||||
|
|
||||||
|
async def fake_append(thread_id: str, finding: Any) -> Any:
|
||||||
|
captured.append(finding)
|
||||||
|
return finding
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.tools.add_finding.get_config", return_value=_config()),
|
||||||
|
patch("agent.tools.add_finding.get_thread_id_from_runtime", return_value="tid-1"),
|
||||||
|
patch(
|
||||||
|
"agent.tools.add_finding.resolve_review_head_sha",
|
||||||
|
AsyncMock(return_value="freshhead"),
|
||||||
|
),
|
||||||
|
patch("agent.tools.add_finding.append_finding", side_effect=fake_append),
|
||||||
|
):
|
||||||
|
result = add_finding(
|
||||||
|
severity="medium",
|
||||||
|
confidence="high",
|
||||||
|
category="style",
|
||||||
|
file="foo.py",
|
||||||
|
title="Rename breaks reference",
|
||||||
|
description="rename",
|
||||||
|
start_line=11,
|
||||||
|
end_line=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert captured[0]["first_seen_sha"] == "freshhead"
|
||||||
|
assert captured[0]["last_confirmed_sha"] == "freshhead"
|
||||||
|
|
||||||
|
|
||||||
def test_add_finding_allows_file_level_with_no_lines() -> None:
|
def test_add_finding_allows_file_level_with_no_lines() -> None:
|
||||||
with (
|
with (
|
||||||
patch("agent.tools.add_finding.get_config", return_value=_config()),
|
patch("agent.tools.add_finding.get_config", return_value=_config()),
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
|
||||||
patch(
|
patch(
|
||||||
"agent.webapp.set_reviewer_thread_metadata",
|
"agent.webapp.set_reviewer_thread_metadata",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
),
|
) as set_meta,
|
||||||
patch("agent.webapp.is_thread_active", new_callable=AsyncMock, return_value=False),
|
patch("agent.webapp.is_thread_active", new_callable=AsyncMock, return_value=False),
|
||||||
patch("agent.webapp.get_client", return_value=fake_client),
|
patch("agent.webapp.get_client", return_value=fake_client),
|
||||||
):
|
):
|
||||||
|
|
@ -278,6 +278,14 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
|
||||||
assert configurable["re_review"] is True
|
assert configurable["re_review"] is True
|
||||||
assert configurable["last_reviewed_sha"] == "oldsha"
|
assert configurable["last_reviewed_sha"] == "oldsha"
|
||||||
assert configurable["head_sha"] == "newsha"
|
assert configurable["head_sha"] == "newsha"
|
||||||
|
# The live head is persisted to thread metadata so a re-review queued into
|
||||||
|
# an in-flight run can resolve it despite the run's frozen config.
|
||||||
|
head_sha_writes = [
|
||||||
|
c.kwargs.get("head_sha")
|
||||||
|
for c in set_meta.await_args_list
|
||||||
|
if c.kwargs.get("head_sha") is not None
|
||||||
|
]
|
||||||
|
assert "newsha" in head_sha_writes
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue