Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/activities/fetch_pr_comments_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
class FetchPRCommentsActivity(BaseActivity[int, List[ExistingCommentThread]]):
"""Activity to fetch existing PR comment threads from Azure DevOps."""

_CODEHAWK_PATTERNS = (
'## 🔴', '## ⚠️', '## 💡',
'# 🤖 AI Code Review', '# 🔍 AI Code Review', '# 🔄 AI Code Re-Review',
'**Issue Fixed**', '**Still present**',
'✅ **Accepted**', '❌ **Finding stands**',
'✅ **Dismissal accepted**',
)

def __init__(self, settings: Settings = None):
super().__init__()
self.settings = settings or get_settings()
Expand Down Expand Up @@ -220,3 +228,67 @@ def _parse_comment_markdown(self, markdown: str) -> dict:
self.logger.warning(f"Failed to parse comment markdown: {e}")

return result

def get_developer_replies(
self, pr_id: int, repository_id: Optional[str] = None,
) -> dict[str, str]:
"""Fetch full conversation thread for each CodeHawk thread with developer replies.

Returns {cr_id: formatted_conversation} where the conversation includes
all comments labeled as [CodeHawk] or [Developer Name].
Only includes threads where at least one developer reply exists.
"""
repo_id = repository_id or self.settings.azure_devops_repo
project = self.settings.azure_devops_project

try:
threads = self.git_client.get_threads(
repository_id=repo_id,
pull_request_id=pr_id,
project=project,
)
except Exception as e:
self.logger.warning("Failed to fetch threads for developer replies: %s", e)
return {}

replies: dict[str, str] = {}
for thread in threads:
if not thread.comments or len(thread.comments) < 2:
continue

cr_id = self._extract_cr_id_from_properties(thread) or self._extract_cr_id(
thread.comments[0].content or ""
)
if not cr_id:
continue

has_developer_reply = False
conversation_lines: list[str] = []

for comment in thread.comments:
content = (comment.content or "").strip()
if not content:
continue
author = getattr(comment, "author", None)
author_unique = getattr(author, "unique_name", "") or ""
author_display = getattr(author, "display_name", "Unknown") or "Unknown"

if self._is_codehawk_comment(content, author_unique):
conversation_lines.append(f"[CodeHawk] {content}")
else:
conversation_lines.append(f"[{author_display}] {content}")
has_developer_reply = True

if has_developer_reply:
replies[cr_id] = "\n\n".join(conversation_lines)

self.logger.info(
"Developer replies: %d threads with replies out of %d total",
len(replies), len(threads),
)
return replies

def _is_codehawk_comment(self, content: str, author_unique_name: str) -> bool:
if not author_unique_name:
return True
return any(content.startswith(p) for p in self._CODEHAWK_PATTERNS)
90 changes: 73 additions & 17 deletions src/batch_review_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def run(self, dry_run: bool = False, commit_id: str = "") -> Dict[str, Any]:
if previous_findings:
return self._run_verify_only(
previous_findings, dry_run=dry_run, commit_id=commit_id,
target_commit=target_commit,
)

# --- Step 3: Build graph once ---
Expand Down Expand Up @@ -198,38 +199,93 @@ def _fetch_pr_details(self):
return None

_VERIFY_MODEL = "gpt-5-codex"
_VERIFY_MAX_TURNS = 5

def _run_verify_only(
self,
previous_findings: list,
dry_run: bool = False,
commit_id: str = "",
target_commit: str = "",
) -> Dict[str, Any]:
"""Run fix verification only (no full review). Cheap agent-based path for re-pushes.
"""Run fix verification using deterministic per-file LLM calls.

Uses a ReviewJob in VERIFY_FIXES mode with gpt-4o-mini and a low turn
budget. The agent has tools (read_local_file, search_code) so it can
investigate cross-file fixes — unlike the deterministic fix_verifier.
Groups findings by file, runs one LLM call per modified file with
the current content + git diff. No agent loop, no sliding window —
every finding is guaranteed to be checked.
"""
from fix_verifier import verify_fixes

logger.info(
"Running fix verification agent (%s, max_turns=%d) for %d prior findings",
self._VERIFY_MODEL, self._VERIFY_MAX_TURNS, len(previous_findings),
"Running deterministic fix verification (%s) for %d prior findings",
self._VERIFY_MODEL, len(previous_findings),
)

config = ReviewJobConfig(
pr_id=self.pr_id,
repo=self.repo,
developer_replies = self._fetch_developer_replies()

verifications, usage = verify_fixes(
old_findings=previous_findings,
workspace=self.workspace,
pr_id=self.pr_id,
repo=self.repo or "",
old_commit=target_commit,
model=self._VERIFY_MODEL,
max_turns=self._VERIFY_MAX_TURNS,
prompt_path=self.prompt_path,
vcs=self.vcs,
previous_findings=previous_findings,
review_mode=ReviewMode.VERIFY_FIXES,
settings=self.settings,
dry_run=True,
developer_replies=developer_replies,
)
job = ReviewJob(config, settings=self.settings)
return job.run(dry_run=dry_run, commit_id=commit_id)

fixed = sum(1 for v in verifications if v.status == "fixed")
dismissed = sum(1 for v in verifications if v.status == "dismissed")
still = sum(1 for v in verifications if v.status == "still_present")
na = sum(1 for v in verifications if v.status == "not_relevant")
findings_data = {
"pr_id": self.pr_id,
"repo": self.repo,
"vcs": self.vcs,
"summary": (
f"Fix verification complete: {fixed} fixed, {dismissed} dismissed, "
f"{still} still present, {na} not relevant "
f"(out of {len(verifications)} prior findings)."
),
"review_modes": ["verify_fixes"],
"agent": "codex",
"findings": [],
"fix_verifications": [
{"cr_id": v.cr_id, "status": v.status, "reason": v.reason}
for v in verifications
],
"usage": usage,
}

findings_path = self.workspace / ".cr" / "findings.json"
findings_path.parent.mkdir(parents=True, exist_ok=True)
findings_path.write_text(json.dumps(findings_data, indent=2), encoding="utf-8")
logger.info(
"Wrote fix verification results: %d verifications", len(verifications),
)

import post_findings as pf
return pf.run(
findings_path=str(findings_path),
dry_run=dry_run,
workspace=str(self.workspace),
commit_id=commit_id,
)

def _fetch_developer_replies(self) -> dict:
"""Fetch developer replies on CodeHawk threads."""
try:
from activities.fetch_pr_comments_activity import FetchPRCommentsActivity
activity = FetchPRCommentsActivity(settings=self.settings)
replies = activity.get_developer_replies(
pr_id=self.pr_id, repository_id=self.repo or None,
)
if replies:
logger.info("Found %d threads with developer replies", len(replies))
return replies
except Exception as exc:
logger.warning("Failed to fetch developer replies: %s", exc)
return {}

def _fetch_previous_findings(self) -> list:
"""Fetch existing review threads with cr_id markers."""
Expand Down
Loading
Loading