Skip to content

Commit 19b763c

Browse files
author
bgagent
committed
feat(agent): post self-review summary as PR comment (#263)
After the self-review phase completes, the agent writes a structured summary of findings to `.self-review-summary.md`. The pipeline reads this file, posts it as a PR comment via `gh pr comment`, then deletes it so it never appears in the PR diff. Fail-open: if the file is missing or the comment fails to post, the pipeline continues normally.
1 parent b53cde9 commit 19b763c

5 files changed

Lines changed: 298 additions & 1 deletion

File tree

agent/src/pipeline.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
_extract_agent_notes,
2424
ensure_committed,
2525
ensure_pr,
26+
post_self_review_comment,
2627
verify_build,
2728
verify_lint,
2829
)
@@ -664,6 +665,10 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
664665
if pr_url:
665666
progress.write_agent_milestone("pr_created", pr_url)
666667

668+
# Post self-review summary as PR comment (if self-review ran and produced findings)
669+
if pr_url and review_result is not None:
670+
post_self_review_comment(setup.repo_dir, pr_url, config)
671+
667672
# Memory write — capture task episode and repo learnings
668673
memory_written = False
669674
effective_memory_id = memory_id or os.environ.get("MEMORY_ID", "")

agent/src/post_hooks.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,68 @@ def ensure_pr(
327327
return None
328328

329329

330+
def post_self_review_comment(repo_dir: str, pr_url: str, config: TaskConfig) -> bool:
331+
"""Post the self-review summary as a PR comment.
332+
333+
Reads the summary file written by the self-review agent, formats it as a
334+
comment, and posts it via `gh pr comment`. Fail-open: exceptions are logged
335+
but never propagated.
336+
337+
Returns True if a comment was posted, False otherwise.
338+
"""
339+
from self_review import read_self_review_summary
340+
341+
try:
342+
summary = read_self_review_summary(repo_dir)
343+
except Exception as e:
344+
log("WARN", f"post_self_review_comment: failed to read summary: {type(e).__name__}: {e}")
345+
return False
346+
347+
if not summary:
348+
log("POST", "post_self_review_comment: no summary file found — skipping")
349+
return False
350+
351+
# Extract PR number from URL (e.g. https://github.com/owner/repo/pull/123)
352+
match = re.search(r"/pull/(\d+)", pr_url)
353+
if not match:
354+
log("WARN", f"post_self_review_comment: could not extract PR number from {pr_url}")
355+
return False
356+
pr_number = match.group(1)
357+
358+
comment_body = f"## \U0001f50d Self-Review Summary\n\n{summary}"
359+
360+
try:
361+
result = subprocess.run(
362+
[
363+
"gh",
364+
"pr",
365+
"comment",
366+
pr_number,
367+
"--repo",
368+
config.repo_url,
369+
"--body",
370+
comment_body,
371+
],
372+
cwd=repo_dir,
373+
capture_output=True,
374+
text=True,
375+
timeout=60,
376+
)
377+
if result.returncode == 0:
378+
log("POST", f"Self-review summary posted as comment on PR #{pr_number}")
379+
return True
380+
stderr = result.stderr.strip()[:200] if result.stderr else ""
381+
log(
382+
"WARN",
383+
f"post_self_review_comment: gh pr comment failed "
384+
f"(rc={result.returncode}): {stderr}",
385+
)
386+
return False
387+
except (subprocess.TimeoutExpired, OSError) as e:
388+
log("WARN", f"post_self_review_comment: {type(e).__name__}: {e}")
389+
return False
390+
391+
330392
def _extract_agent_notes(repo_dir: str, branch: str, config: TaskConfig) -> str | None:
331393
"""Extract the "## Agent notes" section from the PR body.
332394

agent/src/prompts/self_review.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,25 @@
3737
concrete bug or security issue.
3838
- Keep fixes minimal and focused. Each fix should be a separate commit with a \
3939
clear message.
40+
41+
## Summary output
42+
43+
After completing your review (whether you made fixes or not), write a file \
44+
`.self-review-summary.md` in the repository root with your findings in this format:
45+
46+
```markdown
47+
### Self-Review Summary
48+
49+
**Findings:** <number of issues found>
50+
**Fixes applied:** <number of fixes committed>
51+
52+
#### Issues found
53+
54+
- <category>: <brief description of issue> — <fixed | not fixed (reason)>
55+
```
56+
57+
If no issues were found, write the file with: "No issues found — code looks good."
58+
59+
This file is a pipeline artifact and will be deleted automatically — it will NOT \
60+
appear in the pull request.
4061
"""

agent/src/self_review.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import contextlib
7+
import os
68
import subprocess
79
from typing import TYPE_CHECKING
810

@@ -196,3 +198,42 @@ def run_self_review(
196198
f"status={review_result.status} turns={review_result.turns}",
197199
)
198200
return review_result
201+
202+
203+
_SUMMARY_FILENAME = ".self-review-summary.md"
204+
205+
206+
def read_self_review_summary(repo_dir: str) -> str | None:
207+
"""Read and delete the self-review summary file.
208+
209+
The self-review agent writes `.self-review-summary.md` in the repo root.
210+
This function reads the content, removes the file (so it never appears in
211+
the PR), and returns the content. Returns None if the file doesn't exist.
212+
"""
213+
summary_path = os.path.join(repo_dir, _SUMMARY_FILENAME)
214+
if not os.path.isfile(summary_path):
215+
return None
216+
217+
try:
218+
with open(summary_path) as f:
219+
content = f.read()
220+
except OSError as e:
221+
log("WARN", f"self_review: failed to read summary file: {type(e).__name__}: {e}")
222+
return None
223+
224+
# Remove the file so it doesn't end up in the PR
225+
try:
226+
os.remove(summary_path)
227+
except OSError as e:
228+
log("WARN", f"self_review: failed to delete summary file: {type(e).__name__}: {e}")
229+
230+
# If the file was staged by the agent, unstage it
231+
with contextlib.suppress(subprocess.TimeoutExpired, OSError):
232+
subprocess.run(
233+
["git", "rm", "--cached", "--ignore-unmatch", "-f", _SUMMARY_FILENAME],
234+
cwd=repo_dir,
235+
capture_output=True,
236+
timeout=30,
237+
)
238+
239+
return content.strip() if content.strip() else None

agent/tests/test_self_review.py

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
"""Unit tests for self_review.py — self-review orchestration module."""
22

3+
import os
34
from unittest.mock import MagicMock, patch
45

56
from models import AgentResult, RepoSetup, TaskConfig
6-
from self_review import _MAX_DIFF_CHARS, _get_diff, _truncate_diff, run_self_review
7+
from self_review import (
8+
_MAX_DIFF_CHARS,
9+
_SUMMARY_FILENAME,
10+
_get_diff,
11+
_truncate_diff,
12+
read_self_review_summary,
13+
run_self_review,
14+
)
715

816

917
def _make_config(**overrides) -> TaskConfig:
@@ -314,3 +322,163 @@ def test_works_with_unlimited_budget(self, mock_asyncio_run, mock_diff):
314322
result = run_self_review(config, setup, agent_result, trajectory, progress)
315323
assert result is not None
316324
assert result.status == "success"
325+
326+
327+
class TestReadSelfReviewSummary:
328+
"""Tests for read_self_review_summary — reads and cleans up the summary file."""
329+
330+
def test_returns_content_when_file_exists(self, tmp_path):
331+
summary_content = (
332+
"### Self-Review Summary\n\n"
333+
"**Findings:** 2\n"
334+
"**Fixes applied:** 1\n\n"
335+
"#### Issues found\n\n"
336+
"- Security: hardcoded token — fixed\n"
337+
"- Style: inconsistent naming — not fixed (cosmetic)\n"
338+
)
339+
(tmp_path / _SUMMARY_FILENAME).write_text(summary_content)
340+
341+
result = read_self_review_summary(str(tmp_path))
342+
343+
assert result == summary_content.strip()
344+
345+
def test_returns_none_when_file_missing(self, tmp_path):
346+
result = read_self_review_summary(str(tmp_path))
347+
assert result is None
348+
349+
def test_deletes_file_after_reading(self, tmp_path):
350+
(tmp_path / _SUMMARY_FILENAME).write_text("No issues found — code looks good.")
351+
352+
read_self_review_summary(str(tmp_path))
353+
354+
assert not (tmp_path / _SUMMARY_FILENAME).exists()
355+
356+
def test_returns_none_for_empty_file(self, tmp_path):
357+
(tmp_path / _SUMMARY_FILENAME).write_text(" \n\n ")
358+
359+
result = read_self_review_summary(str(tmp_path))
360+
361+
assert result is None
362+
363+
def test_returns_none_for_whitespace_only(self, tmp_path):
364+
(tmp_path / _SUMMARY_FILENAME).write_text("\t\n ")
365+
366+
result = read_self_review_summary(str(tmp_path))
367+
368+
assert result is None
369+
370+
@patch("self_review.subprocess.run")
371+
def test_runs_git_rm_cached_for_cleanup(self, mock_run, tmp_path):
372+
(tmp_path / _SUMMARY_FILENAME).write_text("Some findings")
373+
mock_run.return_value = MagicMock(returncode=0)
374+
375+
read_self_review_summary(str(tmp_path))
376+
377+
mock_run.assert_called_once_with(
378+
["git", "rm", "--cached", "--ignore-unmatch", "-f", _SUMMARY_FILENAME],
379+
cwd=str(tmp_path),
380+
capture_output=True,
381+
timeout=30,
382+
)
383+
384+
@patch("self_review.subprocess.run", side_effect=OSError("git not found"))
385+
def test_git_rm_failure_does_not_block(self, mock_run, tmp_path):
386+
(tmp_path / _SUMMARY_FILENAME).write_text("Findings here")
387+
388+
# Should not raise even if git rm fails
389+
result = read_self_review_summary(str(tmp_path))
390+
391+
assert result == "Findings here"
392+
393+
394+
class TestPostSelfReviewComment:
395+
"""Tests for post_hooks.post_self_review_comment."""
396+
397+
def test_posts_comment_on_success(self, tmp_path):
398+
from post_hooks import post_self_review_comment
399+
400+
(tmp_path / _SUMMARY_FILENAME).write_text("**Findings:** 1\n**Fixes applied:** 1")
401+
402+
config = MagicMock()
403+
config.repo_url = "owner/repo"
404+
pr_url = "https://github.com/owner/repo/pull/42"
405+
406+
with patch("post_hooks.subprocess.run") as mock_run:
407+
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
408+
result = post_self_review_comment(str(tmp_path), pr_url, config)
409+
410+
assert result is True
411+
call_args = mock_run.call_args[0][0]
412+
assert call_args[0:3] == ["gh", "pr", "comment"]
413+
assert "42" in call_args
414+
assert "owner/repo" in call_args
415+
416+
def test_returns_false_when_no_summary(self, tmp_path):
417+
from post_hooks import post_self_review_comment
418+
419+
config = MagicMock()
420+
config.repo_url = "owner/repo"
421+
pr_url = "https://github.com/owner/repo/pull/42"
422+
423+
result = post_self_review_comment(str(tmp_path), pr_url, config)
424+
assert result is False
425+
426+
def test_returns_false_on_invalid_pr_url(self, tmp_path):
427+
from post_hooks import post_self_review_comment
428+
429+
(tmp_path / _SUMMARY_FILENAME).write_text("Some findings")
430+
431+
config = MagicMock()
432+
config.repo_url = "owner/repo"
433+
pr_url = "https://github.com/owner/repo/issues/42"
434+
435+
result = post_self_review_comment(str(tmp_path), pr_url, config)
436+
assert result is False
437+
438+
def test_returns_false_on_gh_failure(self, tmp_path):
439+
from post_hooks import post_self_review_comment
440+
441+
(tmp_path / _SUMMARY_FILENAME).write_text("**Findings:** 1")
442+
443+
config = MagicMock()
444+
config.repo_url = "owner/repo"
445+
pr_url = "https://github.com/owner/repo/pull/99"
446+
447+
with patch("post_hooks.subprocess.run") as mock_run:
448+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not found")
449+
result = post_self_review_comment(str(tmp_path), pr_url, config)
450+
451+
assert result is False
452+
453+
def test_fail_open_on_exception(self, tmp_path):
454+
from post_hooks import post_self_review_comment
455+
456+
(tmp_path / _SUMMARY_FILENAME).write_text("**Findings:** 1")
457+
458+
config = MagicMock()
459+
config.repo_url = "owner/repo"
460+
pr_url = "https://github.com/owner/repo/pull/5"
461+
462+
with patch("post_hooks.subprocess.run", side_effect=OSError("network error")):
463+
result = post_self_review_comment(str(tmp_path), pr_url, config)
464+
465+
assert result is False
466+
467+
def test_comment_body_includes_header(self, tmp_path):
468+
from post_hooks import post_self_review_comment
469+
470+
(tmp_path / _SUMMARY_FILENAME).write_text("**Findings:** 0\nNo issues.")
471+
472+
config = MagicMock()
473+
config.repo_url = "owner/repo"
474+
pr_url = "https://github.com/owner/repo/pull/7"
475+
476+
with patch("post_hooks.subprocess.run") as mock_run:
477+
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
478+
post_self_review_comment(str(tmp_path), pr_url, config)
479+
480+
call_args = mock_run.call_args[0][0]
481+
body_idx = call_args.index("--body") + 1
482+
body = call_args[body_idx]
483+
assert "Self-Review Summary" in body
484+
assert "**Findings:** 0" in body

0 commit comments

Comments
 (0)