406 lines
12 KiB
Python
406 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""QwenPaw AI Review Bot - Main runner script.
|
|
|
|
This script runs inside GitHub Actions to:
|
|
1. Read PR number and repo from environment variables
|
|
2. Send a task prompt to the local QwenPaw instance
|
|
3. QwenPaw autonomously fetches PR data via `gh` CLI
|
|
4. Parse the response and output verdict + review text
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
import httpx
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
# pylint: disable=wrong-import-position
|
|
from prompts import build_review_prompt # noqa: E402
|
|
from pineagents.agents.tools.agent_management import ( # noqa: E402
|
|
extract_agent_text_content,
|
|
parse_agent_sse_line,
|
|
)
|
|
|
|
# pylint: enable=wrong-import-position
|
|
|
|
QWENPAW_URL = "http://localhost:8088"
|
|
CHAT_ENDPOINT = f"{QWENPAW_URL}/api/console/chat"
|
|
MAX_RETRIES = 3
|
|
TIMEOUT_SECONDS = 300
|
|
|
|
|
|
def _extract_stream_text(evt: dict) -> str:
|
|
"""Extract text from a single SSE payload (streaming or final)."""
|
|
text = extract_agent_text_content(evt)
|
|
if text:
|
|
return text
|
|
|
|
content = evt.get("content")
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
parts.append(item.get("text", ""))
|
|
elif isinstance(item, dict) and "text" in item:
|
|
parts.append(str(item["text"]))
|
|
return "".join(parts)
|
|
|
|
fallback = evt.get("text")
|
|
return fallback if isinstance(fallback, str) else ""
|
|
|
|
|
|
def call_qwenpaw(prompt: str, session_id: str) -> str:
|
|
"""Send prompt to QwenPaw console chat API and collect SSE response."""
|
|
payload = {
|
|
"channel": "console",
|
|
"user_id": "review-bot",
|
|
"session_id": session_id,
|
|
"input": [{"content": [{"type": "text", "text": prompt}]}],
|
|
}
|
|
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
try:
|
|
print(f"[attempt {attempt}/{MAX_RETRIES}] Calling QwenPaw...")
|
|
final_event = None
|
|
stream_errors = []
|
|
|
|
with httpx.Client(timeout=TIMEOUT_SECONDS) as client:
|
|
with client.stream(
|
|
"POST",
|
|
CHAT_ENDPOINT,
|
|
json=payload,
|
|
) as resp:
|
|
if resp.status_code != 200:
|
|
print(f" HTTP {resp.status_code}, retrying...")
|
|
time.sleep(5)
|
|
continue
|
|
|
|
for line in resp.iter_lines():
|
|
if not line or not line.startswith("data: "):
|
|
continue
|
|
if line[6:] == "[DONE]":
|
|
break
|
|
|
|
parsed = parse_agent_sse_line(line)
|
|
if not parsed:
|
|
continue
|
|
if parsed.get("error"):
|
|
stream_errors.append(str(parsed["error"]))
|
|
if parsed.get("type") == "turn_usage":
|
|
continue
|
|
final_event = parsed
|
|
|
|
if stream_errors:
|
|
print(f" Stream errors: {'; '.join(stream_errors)}")
|
|
|
|
response = _extract_stream_text(final_event or {})
|
|
if response.strip():
|
|
return response
|
|
|
|
print(" Empty response, retrying...")
|
|
time.sleep(5)
|
|
|
|
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
|
print(f" Error: {e}, retrying...")
|
|
time.sleep(5)
|
|
|
|
return ""
|
|
|
|
|
|
def validate_response(response: str, pr_number: int) -> list[str]:
|
|
"""Check that the response contains signs of real PR data.
|
|
|
|
Returns a list of warning messages (empty = all checks passed).
|
|
"""
|
|
warnings = []
|
|
if f"#{pr_number}" not in response and str(pr_number) not in response:
|
|
warnings.append(
|
|
f"Response does not mention PR #{pr_number} — "
|
|
f"agent may not have fetched PR data",
|
|
)
|
|
structure_markers = ["### 1.", "### 2.", "### 3."]
|
|
missing = [m for m in structure_markers if m not in response]
|
|
if missing:
|
|
warnings.append(
|
|
f"Missing expected sections: {', '.join(missing)}",
|
|
)
|
|
return warnings
|
|
|
|
|
|
def parse_verdict(response: str) -> dict:
|
|
"""Extract verdict and issue counts from the Summary section.
|
|
|
|
Scopes the search to ``### 6. Summary`` to avoid matching
|
|
unrelated JSON code blocks elsewhere in the review.
|
|
"""
|
|
default = {
|
|
"verdict": "REQUEST_CHANGES",
|
|
"high_count": -1,
|
|
"medium_count": -1,
|
|
"low_count": -1,
|
|
}
|
|
summary_match = re.search(r"###\s*6[.\s]", response)
|
|
search_text = (
|
|
response[summary_match.start() :] if summary_match else response
|
|
)
|
|
|
|
match = re.search(
|
|
r"```json\s*(\{[\s\S]*?\})\s*```",
|
|
search_text,
|
|
)
|
|
if not match:
|
|
return default
|
|
try:
|
|
result = json.loads(match.group(1))
|
|
except json.JSONDecodeError:
|
|
return default
|
|
|
|
verdict = result.get("verdict", "REQUEST_CHANGES")
|
|
if verdict not in ("APPROVE", "REQUEST_CHANGES"):
|
|
verdict = "REQUEST_CHANGES"
|
|
|
|
return {
|
|
"verdict": verdict,
|
|
"high_count": int(result.get("high_count", -1)),
|
|
"medium_count": int(result.get("medium_count", -1)),
|
|
"low_count": int(result.get("low_count", -1)),
|
|
}
|
|
|
|
|
|
def _strip_summary_verdict_json(text: str) -> str:
|
|
"""Strip the verdict JSON block from the '### 6. Summary' section only.
|
|
|
|
Matches a ```json ... ``` block that contains a "verdict" key
|
|
and appears after the '### 6' heading. Other JSON blocks
|
|
elsewhere in the review (e.g. code examples) are preserved.
|
|
"""
|
|
summary_match = re.search(r"(###\s*6[.\s])", text)
|
|
if not summary_match:
|
|
return text
|
|
|
|
before = text[: summary_match.start()]
|
|
summary_section = text[summary_match.start() :]
|
|
|
|
cleaned = re.sub(
|
|
r"\n*```json\s*\{[\s\S]*?\"verdict\"[\s\S]*?\}\s*```\n*",
|
|
"\n",
|
|
summary_section,
|
|
)
|
|
return (before + cleaned).rstrip()
|
|
|
|
|
|
_FENCE_RE = re.compile(r"^(`{3,})(.*)")
|
|
|
|
|
|
def _scan_fence_block(
|
|
lines: list[str],
|
|
start: int,
|
|
tick_len: int,
|
|
) -> tuple[list[str], int]:
|
|
"""Find the matching closer for a code fence.
|
|
|
|
Tracks open/close depth so that LLM-produced
|
|
pseudo-nested fences are handled correctly.
|
|
|
|
Returns ``(body_lines, close_index)``.
|
|
``close_index`` is ``-1`` if no closer is found.
|
|
"""
|
|
depth = 1
|
|
body: list[str] = []
|
|
for j in range(start, len(lines)):
|
|
fm = re.match(rf"^`{{{tick_len},}}", lines[j])
|
|
if fm:
|
|
rest = lines[j][len(fm.group(0)) :].strip()
|
|
if rest:
|
|
depth += 1
|
|
else:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return body, j
|
|
body.append(lines[j])
|
|
return body, -1
|
|
|
|
|
|
def _fix_nested_code_fences(text: str) -> str:
|
|
"""Bump outer fence width when content has inner fences.
|
|
|
|
LLMs often produce pseudo-nested fences where inner
|
|
````` ``` ````` markers break the outer block. This
|
|
function uses depth tracking to find the intended
|
|
closer, then increases the outer fence length so
|
|
inner fences become harmless content.
|
|
"""
|
|
lines = text.split("\n")
|
|
out: list[str] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
m = _FENCE_RE.match(lines[i])
|
|
if not m:
|
|
out.append(lines[i])
|
|
i += 1
|
|
continue
|
|
|
|
info = m.group(2).strip()
|
|
n = len(m.group(1))
|
|
body, close = _scan_fence_block(lines, i + 1, n)
|
|
|
|
max_inner = 0
|
|
for bline in body:
|
|
im = re.match(r"^(`{3,})", bline)
|
|
if im and len(im.group(1)) > max_inner:
|
|
max_inner = len(im.group(1))
|
|
|
|
if max_inner >= n:
|
|
fence = "`" * (max_inner + 1)
|
|
tag = f"{fence}{info}" if info else fence
|
|
out.append(tag)
|
|
out.extend(body)
|
|
if close >= 0:
|
|
out.append(fence)
|
|
else:
|
|
out.append(lines[i])
|
|
out.extend(body)
|
|
if close >= 0:
|
|
out.append(lines[close])
|
|
|
|
i = close + 1 if close >= 0 else len(lines)
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
_SECRET_ENV_NAMES = [
|
|
"DASHSCOPE_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
"AZURE_OPENAI_API_KEY",
|
|
"GOOGLE_API_KEY",
|
|
"HUGGINGFACE_TOKEN",
|
|
"HF_TOKEN",
|
|
"GH_TOKEN",
|
|
"GITHUB_TOKEN",
|
|
]
|
|
|
|
_SECRET_PREFIXES = ("sk-", "ghp_", "gho_", "ghu_", "ghs_", "ghr_")
|
|
|
|
|
|
def _scan_for_leaked_secrets(text: str) -> list[str]:
|
|
"""Check review text for potential secret values.
|
|
|
|
Returns a list of warning messages for each detected leak.
|
|
"""
|
|
warnings = []
|
|
for name in _SECRET_ENV_NAMES:
|
|
value = os.environ.get(name, "").strip()
|
|
if value and len(value) >= 8 and value in text:
|
|
warnings.append(
|
|
f"Review text contains value of ${name}",
|
|
)
|
|
for prefix in _SECRET_PREFIXES:
|
|
pattern = re.compile(
|
|
re.escape(prefix) + r"[A-Za-z0-9_\-]{20,}",
|
|
)
|
|
if pattern.search(text):
|
|
warnings.append(
|
|
f"Review text contains token-like string "
|
|
f"matching prefix '{prefix}'",
|
|
)
|
|
return warnings
|
|
|
|
|
|
def _redact_secrets(text: str) -> str:
|
|
"""Replace known secret values in text with [REDACTED]."""
|
|
result = text
|
|
for name in _SECRET_ENV_NAMES:
|
|
value = os.environ.get(name, "").strip()
|
|
if value and len(value) >= 8:
|
|
result = result.replace(value, "[REDACTED]")
|
|
for prefix in _SECRET_PREFIXES:
|
|
result = re.sub(
|
|
re.escape(prefix) + r"[A-Za-z0-9_\-]{20,}",
|
|
"[REDACTED]",
|
|
result,
|
|
)
|
|
return result
|
|
|
|
|
|
def write_outputs(verdict_info: dict, review_text: str):
|
|
"""Write results to GITHUB_OUTPUT and temp file for later steps."""
|
|
output_file = os.environ.get("GITHUB_OUTPUT", "")
|
|
if output_file:
|
|
with open(output_file, "a", encoding="utf-8") as f:
|
|
f.write(f"verdict={verdict_info['verdict']}\n")
|
|
f.write(f"high_count={verdict_info['high_count']}\n")
|
|
f.write(f"medium_count={verdict_info['medium_count']}\n")
|
|
|
|
clean_text = _strip_summary_verdict_json(review_text)
|
|
clean_text = _fix_nested_code_fences(clean_text)
|
|
|
|
leak_warnings = _scan_for_leaked_secrets(clean_text)
|
|
if leak_warnings:
|
|
for w in leak_warnings:
|
|
print(f" 🚨 SECRET LEAK DETECTED: {w}")
|
|
clean_text = _redact_secrets(clean_text)
|
|
print(" Secrets have been redacted from review output.")
|
|
|
|
with open("/tmp/review_result.md", "w", encoding="utf-8") as f:
|
|
f.write(clean_text)
|
|
|
|
|
|
def main():
|
|
print("=" * 60)
|
|
print("QwenPaw AI Review Bot")
|
|
print("=" * 60)
|
|
|
|
pr_number = os.environ.get("PR_NUMBER")
|
|
repo = os.environ.get("PR_REPO")
|
|
|
|
if not pr_number or not repo:
|
|
print(
|
|
"ERROR: PR_NUMBER and PR_REPO environment variables "
|
|
"are required.",
|
|
)
|
|
sys.exit(1)
|
|
|
|
pr_number = int(pr_number)
|
|
print(f"\nTarget: {repo} PR #{pr_number}")
|
|
|
|
prompt = build_review_prompt(pr_number, repo)
|
|
print(f"Prompt size: {len(prompt)} chars")
|
|
|
|
session_id = f"pr-review-{pr_number}-{int(time.time())}"
|
|
print(f"Session: {session_id}")
|
|
print("Sending task to QwenPaw (agent will fetch PR data via gh)...")
|
|
|
|
response = call_qwenpaw(prompt, session_id)
|
|
|
|
if not response.strip():
|
|
print("\n❌ ERROR: Got empty response from QwenPaw")
|
|
sys.exit(1)
|
|
|
|
warnings = validate_response(response, pr_number)
|
|
if warnings:
|
|
for w in warnings:
|
|
print(f" ⚠️ {w}")
|
|
|
|
verdict_info = parse_verdict(response)
|
|
verdict = verdict_info["verdict"]
|
|
high = verdict_info["high_count"]
|
|
medium = verdict_info["medium_count"]
|
|
|
|
print(f"\n{'✅' if verdict == 'APPROVE' else '⚠️'} Verdict: {verdict}")
|
|
print(f"Issues: High={high}, Medium={medium}")
|
|
print(f"Response length: {len(response)} chars")
|
|
|
|
write_outputs(verdict_info, response)
|
|
print("\n✅ Done! Results written to /tmp/review_result.md")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|