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
107 changes: 50 additions & 57 deletions .github/scripts/review_publishing.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class InlineReviewPlan:

@dataclass(frozen=True)
class InlinePublishResult:
created_review: bool
created_comments: bool
posted: int
updated: int
resolved: int
Expand Down Expand Up @@ -142,8 +142,13 @@ def pr_review_doc_path(pr_number: int) -> str:
return f"{DOC_ROOT}/PR-{pr_number}.md"


def _pr_number_from_review_doc_path(path: str) -> int | None:
match = re.fullmatch(r"docs/pr-reviews/PR-([1-9][0-9]*)\.md", path.strip())
return int(match.group(1)) if match else None


def is_pr_review_doc_path(path: str) -> bool:
return bool(re.fullmatch(r"docs/pr-reviews/PR-[1-9][0-9]*\.md", path.strip()))
return _pr_number_from_review_doc_path(path) is not None


def parse_right_side_lines(files: Sequence[Mapping[str, Any]]) -> dict[str, set[int]]:
Expand Down Expand Up @@ -429,53 +434,47 @@ def publish_inline_review_comments(
if not new_comments:
return InlinePublishResult(False, 0, updated, resolved, plan.fallback_findings)

payload = {
"commit_id": commit_id,
"body": "WhyLog AI 자동 줄 단위 리뷰입니다.",
"event": "COMMENT",
"comments": [
{
"path": comment.path,
"line": comment.line,
"side": "RIGHT",
"body": comment.body,
}
for comment in new_comments
],
}
try:
github_request(
api_url,
token,
f"/repos/{repository}/pulls/{pr_number}/reviews",
method="POST",
payload=payload,
)
except Exception as error:
if _is_status_error(error, 422):
fallback = list(plan.fallback_findings)
fallback.extend(
{
"kind": comment.kind,
"file": comment.path,
posted = 0
fallback = list(plan.fallback_findings)
first_error: str | None = None
for comment in new_comments:
try:
github_request(
api_url,
token,
f"/repos/{repository}/pulls/{pr_number}/comments",
method="POST",
payload={
"commit_id": commit_id,
"path": comment.path,
"line": comment.line,
"fingerprint": comment.fingerprint,
"fallback_reason": "github_inline_review_422",
}
for comment in new_comments
)
return InlinePublishResult(
created_review=False,
posted=0,
updated=updated,
resolved=resolved,
fallback_findings=tuple(fallback),
post_failed_fallback=str(error)[:500],
"side": "RIGHT",
"body": comment.body,
},
)
raise
except Exception as error:
if _is_status_error(error, 422):
fallback.append(
{
"kind": comment.kind,
"file": comment.path,
"line": comment.line,
"fingerprint": comment.fingerprint,
"fallback_reason": "github_inline_comment_422",
}
)
first_error = first_error or str(error)[:500]
continue
raise
posted += 1

return InlinePublishResult(
True, len(new_comments), updated, resolved, plan.fallback_findings
posted > 0,
posted,
updated,
resolved,
tuple(fallback),
first_error,
)


Expand Down Expand Up @@ -985,16 +984,7 @@ def sync_pr_review_document(


def _doc_commit_message(pr_number: int) -> str:
return (
f"docs(docs): PR-{pr_number} 리뷰 판단 근거를 저장소에 남김\n\n"
"Constraint: CI AI review result must remain readable from repository docs\n"
"Rejected: Direct push to protected base with the default GITHUB_TOKEN | branch protection and auditability require branch-scoped document updates only\n"
"Confidence: medium\n"
"Scope-risk: narrow\n"
"Directive: Do not edit generated state markers by hand\n"
"Tested: AI review publishing workflow generated this document\n"
"Generated-By: whylog-ai-review\n"
)
return f"docs(docs): PR-{pr_number} 리뷰 판단 근거 기록"


def generated_doc_only_parent_sha(
Expand Down Expand Up @@ -1022,8 +1012,6 @@ def generated_doc_only_parent_sha(
raw_files = commit.get("files")
if isinstance(raw_files, list):
files = raw_files
if "Generated-By: whylog-ai-review" not in message:
return None
paths = [
item.get("filename")
for item in files
Expand All @@ -1038,6 +1026,11 @@ def generated_doc_only_parent_sha(
or not isinstance(parents[0], Mapping)
):
return None
document_pr_number = _pr_number_from_review_doc_path(paths[0])
if document_pr_number is None or message != _doc_commit_message(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WhyLog AI 리뷰

  1. 제안: GitHub API 커밋 메시지의 개행 문자(\n) 트림 처리 권장
    • 이유: GitHub REST API로 커밋 정보를 조회할 때 commit.message 문자열 끝에 개행 문자(\n)가 포함되어 들어올 수 있습니다. message_doc_commit_message(...)를 직접 등가 비교(!=)할 경우 개행 문자로 인해 감지 실패(None 반환)가 발생할 가능성이 있습니다.
    • 근거: CI 자동화 스크립트 예외 처리 규칙
    • 제안 수정: message.strip() != _doc_commit_message(document_pr_number)와 같이 .strip()을 사용하여 개행 여부와 관계없이 안전하게 일치 여부를 검증하도록 수정하는 것을 권장합니다.

document_pr_number
):
return None
return _string(parents[0].get("sha")) or None


Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/test_ai_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def test_renders_inline_and_document_publish_status(self) -> None:
ai_review.Review("요약", (), ()),
)
inline = ai_review.review_publishing.InlinePublishResult(
created_review=True,
created_comments=True,
posted=2,
updated=1,
resolved=1,
Expand Down Expand Up @@ -391,7 +391,7 @@ def test_run_reviews_internal_public_pull_request(
}

inline_result = ai_review.review_publishing.InlinePublishResult(
created_review=False,
created_comments=False,
posted=0,
updated=0,
resolved=0,
Expand Down
107 changes: 88 additions & 19 deletions .github/scripts/test_review_publishing.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def test_updates_duplicate_and_marks_stale_resolved(self):
self.assertIn("/pulls/comments/10", github.calls[1][0])
self.assertIn("재검출되지 않음", github.calls[2][2]["body"])

def test_posts_new_comments_as_one_review(self):
def test_posts_new_comments_individually_without_review_wrapper(self):
github = FakeGitHub(responses=[[], {"id": 1}])

published = rp.publish_inline_review_comments(
Expand All @@ -182,16 +182,18 @@ def test_posts_new_comments_as_one_review(self):
github,
)

self.assertTrue(published.created_review)
self.assertTrue(published.created_comments)
self.assertEqual(published.posted, 1)
self.assertEqual(
github.calls[-1][0], "/repos/WhyLog-App/WhyLog/pulls/7/reviews"
github.calls[-1][0], "/repos/WhyLog-App/WhyLog/pulls/7/comments"
)
self.assertEqual(
github.calls[-1][2]["body"], "WhyLog AI 자동 줄 단위 리뷰입니다."
self.assertTrue(
github.calls[-1][2]["body"].startswith("<!-- whylog-ai-inline-review:")
)
self.assertEqual(github.calls[-1][2]["event"], "COMMENT")
self.assertEqual(github.calls[-1][2]["comments"][0]["side"], "RIGHT")
self.assertIn("규칙 위반", github.calls[-1][2]["body"])
self.assertEqual(github.calls[-1][2]["side"], "RIGHT")
self.assertEqual(github.calls[-1][2]["line"], 2)
self.assertEqual(github.calls[-1][2]["commit_id"], "abc")

def test_duplicate_active_comments_are_consolidated(self):
current = finding("block", "server/src/App.java", 2)
Expand Down Expand Up @@ -287,6 +289,38 @@ def test_old_commit_comment_is_replaced_on_current_commit(self):
self.assertEqual(github.calls[2][1], "POST")
self.assertEqual(github.calls[2][2]["commit_id"], "new-commit")

def test_one_rejected_comment_does_not_drop_other_inline_comments(self):
github = FakeGitHub(
responses=[[], {"id": 20}],
errors=[None, HttpError(422), None],
)

published = rp.publish_inline_review_comments(
"api",
"token",
"WhyLog-App/WhyLog",
7,
"abc",
files(),
result(
blocking=(finding("b", "server/src/App.java", 2),),
suggestions=(finding("s", "server/src/App.java", 12),),
),
github,
)

self.assertTrue(published.created_comments)
self.assertEqual(published.posted, 1)
self.assertEqual(len(published.fallback_findings), 1)
self.assertEqual(published.fallback_findings[0]["line"], 2)
self.assertEqual(
[call[0] for call in github.calls[1:]],
[
"/repos/WhyLog-App/WhyLog/pulls/7/comments",
"/repos/WhyLog-App/WhyLog/pulls/7/comments",
],
)

def test_post_422_returns_fallback_instead_of_raising(self):
github = FakeGitHub(responses=[[]], errors=[None, HttpError(422)])

Expand All @@ -301,11 +335,11 @@ def test_post_422_returns_fallback_instead_of_raising(self):
github,
)

self.assertFalse(published.created_review)
self.assertFalse(published.created_comments)
self.assertEqual(published.posted, 0)
self.assertEqual(
published.fallback_findings[-1]["fallback_reason"],
"github_inline_review_422",
"github_inline_comment_422",
)
self.assertIn("HTTP 422", published.post_failed_fallback or "")

Expand Down Expand Up @@ -557,14 +591,9 @@ def test_sync_creates_commit_from_reviewed_head(self):
commit_call = github.calls[-2]
self.assertEqual(commit_call[0], "/repos/repo/git/commits")
self.assertEqual(commit_call[2]["parents"], ["headsha"])
self.assertIn("Generated-By: whylog-ai-review", commit_call[2]["message"])
self.assertTrue(
commit_call[2]["message"].startswith(
"docs(docs): PR-7 리뷰 판단 근거를 저장소에 남김"
)
)
self.assertIn(
"Rejected: Direct push to protected base", commit_call[2]["message"]
self.assertEqual(
commit_call[2]["message"],
"docs(docs): PR-7 리뷰 판단 근거 기록",
)

def test_sync_stale_head_is_artifact_only(self):
Expand Down Expand Up @@ -602,7 +631,7 @@ def test_detects_generated_doc_only_head_commit(self):
responses=[
{
"commit": {
"message": "docs(docs): PR-7 리뷰 판단 근거를 저장소에 남김\n\nGenerated-By: whylog-ai-review\n"
"message": "docs(docs): PR-7 리뷰 판단 근거 기록"
},
"files": [{"filename": "docs/pr-reviews/PR-7.md"}],
"parents": [{"sha": "parent-sha"}],
Expand All @@ -617,7 +646,7 @@ def test_detects_generated_doc_only_head_commit(self):
"parent-sha",
)

def test_rejects_non_review_doc_or_missing_trailer(self):
def test_rejects_non_review_doc_or_wrong_message(self):
github = FakeGitHub(
responses=[
{
Expand All @@ -633,6 +662,46 @@ def test_rejects_non_review_doc_or_missing_trailer(self):
"api", "token", "repo", {"head": {"sha": "abc"}}, github
)
)

def test_rejects_mismatched_pr_number_in_generated_message(self):
github = FakeGitHub(
responses=[
{
"commit": {
"message": "docs(docs): PR-8 리뷰 판단 근거 기록"
},
"files": [{"filename": "docs/pr-reviews/PR-7.md"}],
"parents": [{"sha": "parent-sha"}],
}
]
)

self.assertIsNone(
rp.generated_doc_only_parent_sha(
"api", "token", "repo", {"head": {"sha": "abc"}}, github
)
)

def test_rejects_invalid_review_doc_number_without_raising(self):
github = FakeGitHub(
responses=[
{
"commit": {
"message": "docs(docs): PR-invalid 리뷰 판단 근거 기록"
},
"files": [{"filename": "docs/pr-reviews/PR-invalid.md"}],
"parents": [{"sha": "parent-sha"}],
}
]
)

self.assertIsNone(
rp.generated_doc_only_parent_sha(
"api", "token", "repo", {"head": {"sha": "abc"}}, github
)
)

def test_recognizes_pr_review_document_path(self):
self.assertTrue(rp.is_pr_review_doc_path("docs/pr-reviews/PR-1.md"))
self.assertFalse(rp.is_pr_review_doc_path("docs/pr-reviews/README.md"))

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ jobs:
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
AI_REVIEW_PUSH_TOKEN: ${{ secrets.AI_REVIEW_PUSH_TOKEN }}
AI_REVIEW_PUSH_TOKEN: ""

@github-actions github-actions Bot Aug 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 실행에서 재검출되지 않음(자동 추정). 사람이 실제 반영 여부를 확인하세요.

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY_IS_PRIVATE: ${{ github.event.repository.private }}
PYTHONUNBUFFERED: "1"
Expand Down