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
51 changes: 50 additions & 1 deletion .github/workflows/ai_review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ jobs:
- name: Load review issue context
id: issue_context
run: |
mkdir -p data/output/ai_review
python3 - <<'PY'
import json
import os
import urllib.request
from pathlib import Path

repo = os.environ["GITHUB_REPOSITORY"]
issue_number = os.environ["ISSUE_NUMBER"]
Expand All @@ -49,6 +51,16 @@ jobs:
with urllib.request.urlopen(request) as response:
issue = json.load(response)

issue_context = {
"number": issue["number"],
"title": issue["title"],
"body": issue["body"],
}
Path("data/output/ai_review/issue_context.json").write_text(
json.dumps(issue_context, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)

with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
print("issue_title<<EOF", file=output)
print(issue["title"], file=output)
Expand Down Expand Up @@ -161,13 +173,50 @@ jobs:
### 操作员待办事项
...

- name: Render Claude primary review
if: steps.claude_review.outcome == 'success'
run: |
python3 scripts/render_monthly_ai_review.py \
--execution-file "${{ steps.claude_review.outputs.execution_file }}" \
--output-file data/output/ai_review/primary_review.md \
--primary-title "Claude Primary Review"

- name: Run GPT secondary review
if: steps.claude_review.outcome == 'success'
run: |
python3 scripts/run_openai_secondary_review.py \
--review-kind upstream_selector \
--issue-context-file data/output/ai_review/issue_context.json \
--primary-review-file data/output/ai_review/primary_review.md \
--output-file data/output/ai_review/secondary_review.json \
--model "${OPENAI_SECONDARY_MODEL}"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_SECONDARY_MODEL: ${{ vars.OPENAI_SECONDARY_MODEL || 'gpt-5.4-mini' }}

- name: Render final AI review markdown
if: steps.claude_review.outcome == 'success'
run: |
python3 scripts/render_monthly_ai_review.py \
--primary-review-file data/output/ai_review/primary_review.md \
--secondary-review-file data/output/ai_review/secondary_review.json \
--output-file data/output/ai_review/final_review.md \
--primary-title "Claude Primary Review"

- name: Upload AI review artifact
if: steps.claude_review.outcome == 'success'
uses: actions/upload-artifact@v7
with:
name: ai-monthly-review-${{ inputs.issue_number || github.event.issue.number }}
path: data/output/ai_review/

- name: Post AI review issue comment
if: steps.claude_review.outcome == 'success'
run: |
python3 scripts/post_monthly_ai_review_comment.py \
--repo "${GITHUB_REPOSITORY}" \
--issue-number "${{ inputs.issue_number || github.event.issue.number }}" \
--execution-file "${{ steps.claude_review.outputs.execution_file }}" \
--review-file data/output/ai_review/final_review.md \
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42 changes: 19 additions & 23 deletions scripts/post_monthly_ai_review_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,18 @@
from pathlib import Path
from typing import Any

try:
from scripts.render_monthly_ai_review import extract_latest_assistant_text
except ModuleNotFoundError: # pragma: no cover - script execution fallback
from render_monthly_ai_review import extract_latest_assistant_text


COMMENT_MARKER = "<!-- monthly-ai-review -->"
DEFAULT_API_URL = "https://api.github.com"


def extract_latest_assistant_text(execution_log: list[dict[str, Any]]) -> str:
for turn in reversed(execution_log):
if turn.get("type") != "assistant":
continue

content_items = turn.get("message", {}).get("content", [])
text_parts = [
item.get("text", "").strip()
for item in content_items
if item.get("type") == "text" and item.get("text", "").strip()
]
if text_parts:
return "\n\n".join(text_parts).strip()

raise ValueError("No assistant review text found in Claude execution log")


def build_comment_body(review_text: str, run_url: str | None = None) -> str:
body = f"{COMMENT_MARKER}\n## Claude Monthly Strategy Review\n\n{review_text.strip()}"
def build_comment_body(review_markdown: str, run_url: str | None = None) -> str:
body = f"{COMMENT_MARKER}\n## AI Monthly Review\n\n{review_markdown.strip()}"
if run_url:
body += f"\n\n---\n_Generated by AI Monthly Review workflow: {run_url}_"
return body
Expand Down Expand Up @@ -101,7 +89,8 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--repo", required=True, help="owner/repo")
parser.add_argument("--issue-number", required=True, type=int)
parser.add_argument("--execution-file", required=True, type=Path)
parser.add_argument("--execution-file", type=Path)
parser.add_argument("--review-file", type=Path)
parser.add_argument("--api-url", default=DEFAULT_API_URL)
parser.add_argument("--run-url", default="")
return parser.parse_args()
Expand All @@ -114,9 +103,16 @@ def main() -> int:
print("GITHUB_TOKEN is required", file=sys.stderr)
return 1

execution_log = json.loads(args.execution_file.read_text(encoding="utf-8"))
review_text = extract_latest_assistant_text(execution_log)
body = build_comment_body(review_text, args.run_url or None)
if args.review_file is not None:
review_markdown = args.review_file.read_text(encoding="utf-8")
elif args.execution_file is not None:
execution_log = json.loads(args.execution_file.read_text(encoding="utf-8"))
review_markdown = extract_latest_assistant_text(execution_log)
else:
print("Either --review-file or --execution-file is required", file=sys.stderr)
return 1

body = build_comment_body(review_markdown, args.run_url or None)

try:
upsert_issue_comment(
Expand Down
120 changes: 120 additions & 0 deletions scripts/render_monthly_ai_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any


def extract_latest_assistant_text(execution_log: list[dict[str, Any]]) -> str:
for turn in reversed(execution_log):
if turn.get("type") != "assistant":
continue

content_items = turn.get("message", {}).get("content", [])
text_parts = [
item.get("text", "").strip()
for item in content_items
if item.get("type") == "text" and item.get("text", "").strip()
]
if text_parts:
return "\n\n".join(text_parts).strip()

raise ValueError("No assistant review text found in execution log")


def load_primary_review_markdown(*, execution_file: Path | None, primary_review_file: Path | None) -> str:
if primary_review_file is not None:
return primary_review_file.read_text(encoding="utf-8").strip()
if execution_file is not None:
execution_log = json.loads(execution_file.read_text(encoding="utf-8"))
return extract_latest_assistant_text(execution_log)
raise ValueError("Either execution_file or primary_review_file is required")


def render_secondary_review_markdown(payload: dict[str, Any]) -> str:
lines: list[str] = [
f"## Secondary Review ({payload.get('provider_display_name', 'GPT')})",
"",
f"- Verdict: `{payload['verdict']}`",
f"- Risk Level: `{payload['risk_level']}`",
f"- Production Recommendation: `{payload['production_recommendation']}`",
f"- Summary: {payload['summary']}",
]

findings = [item.strip() for item in payload.get("key_findings", []) if str(item).strip()]
if findings:
lines.extend(["", "### Key Findings"])
lines.extend(f"- {item}" for item in findings)

actions = payload.get("recommended_actions", [])
if actions:
lines.extend(["", "### Recommended Actions"])
for action in actions:
flags: list[str] = []
if action.get("auto_pr_safe"):
flags.append("auto-pr-safe")
if action.get("experiment_only"):
flags.append("experiment-only")
flag_text = f" [{', '.join(flags)}]" if flags else ""
lines.append(
"- "
f"{action['title']} "
f"({action['owner_repo']}, risk={action['risk_level']}){flag_text}: {action['summary']}"
)

follow_up_checks = [item.strip() for item in payload.get("follow_up_checks", []) if str(item).strip()]
if follow_up_checks:
lines.extend(["", "### Follow-up Checks"])
lines.extend(f"- {item}" for item in follow_up_checks)

return "\n".join(lines).strip()


def build_full_review_markdown(
primary_review_text: str,
*,
primary_title: str,
secondary_review_payload: dict[str, Any] | None = None,
) -> str:
lines = [f"## {primary_title}", "", primary_review_text.strip()]
if secondary_review_payload is not None:
lines.extend(["", "---", "", render_secondary_review_markdown(secondary_review_payload)])
return "\n".join(lines).strip() + "\n"


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render markdown for the monthly AI review from primary and optional secondary review outputs.",
)
parser.add_argument("--output-file", required=True, type=Path)
parser.add_argument("--execution-file", type=Path)
parser.add_argument("--primary-review-file", type=Path)
parser.add_argument("--secondary-review-file", type=Path)
parser.add_argument("--primary-title", default="Claude Primary Review")
return parser.parse_args()


def main() -> int:
args = parse_args()
primary_review_text = load_primary_review_markdown(
execution_file=args.execution_file,
primary_review_file=args.primary_review_file,
)
secondary_review_payload = None
if args.secondary_review_file is not None:
secondary_review_payload = json.loads(args.secondary_review_file.read_text(encoding="utf-8"))

markdown = build_full_review_markdown(
primary_review_text,
primary_title=args.primary_title,
secondary_review_payload=secondary_review_payload,
)
args.output_file.parent.mkdir(parents=True, exist_ok=True)
args.output_file.write_text(markdown, encoding="utf-8")
print(f"review_markdown={args.output_file}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading