feat(code-review): 重建為 Post-Deploy AI Agent Pipeline
All checks were successful
CD Pipeline / deploy (push) Successful in 1m21s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m21s
架構重建: - 移除 pre-commit hook(本機 commit 不再阻塞) - 改為 CD 健康檢查通過後自動觸發 webhook 新建 services/code_review_pipeline_service.py: 5-Step Pipeline(後台 daemon thread) Step1 system 讀取部署後變更檔案內容 Step2 Hermes 程式碼掃描(bugs/security/perf,hermes3:latest) Step3 OpenClaw 架構品質評估(Gemini 2.5 Flash) Step4 ElephantAlpha 決策協調(severity + auto_fix 裁量) Step5 NemoTron action_plans 寫入 + AiderHeal 觸發 全程 Telegram 告警(啟動/完成/錯誤)+ ai_insights DB 持久化 重建 routes/code_review_routes.py: POST /code-review/api/internal/trigger CD webhook(X-Internal-Token) GET /code-review/api/status 前端即時 polling GET /code-review/api/history 歷史清單 GET /code-review/ 前端儀表板 重建 templates/code_review.html: 深色儀表板,Pipeline 即時進度 + Severity 分佈 + 問題清單 + EA 決策 3s polling(running)/ 30s(idle) .gitea/workflows/cd.yaml: 健康檢查通過後注入「觸發 AI Code Review」step continue-on-error: true(不影響部署結果) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,22 @@ jobs:
|
||||
echo "❌ 健康檢查失敗"
|
||||
exit 1
|
||||
|
||||
# ── 觸發 Post-Deploy Code Review ─────────────────────────────────────
|
||||
- name: 觸發 AI Code Review
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
|
||||
FILES_JSON=$(echo "$CHANGED" | grep -E '\.(py|yaml|yml|json)$' | \
|
||||
jq -Rs '[split("\n")[] | select(. != "")]')
|
||||
curl -fS --max-time 10 \
|
||||
-X POST "https://mo.wooo.work/code-review/api/internal/trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Internal-Token: ${{ secrets.INTERNAL_WEBHOOK_TOKEN }}" \
|
||||
-d "{\"commit_sha\":\"${{ github.sha }}\",\"changed_files\":${FILES_JSON},\"branch\":\"${{ github.ref_name }}\",\"deploy_type\":\"${{ steps.deploy_type.outputs.type }}\"}" \
|
||||
&& echo "✅ Code Review Pipeline 已觸發" \
|
||||
|| echo "⚠️ Code Review webhook 呼叫失敗(不影響部署結果)"
|
||||
|
||||
# ── 部署成功通知 ──────────────────────────────────────────────────────
|
||||
- name: 通知部署成功
|
||||
if: success()
|
||||
|
||||
107
routes/code_review_routes.py
Normal file
107
routes/code_review_routes.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
routes/code_review_routes.py
|
||||
Code Review 路由層
|
||||
|
||||
端點:
|
||||
POST /code-review/api/internal/trigger — CD Webhook(部署後觸發)
|
||||
GET /code-review/api/status — 前端 polling 即時狀態
|
||||
GET /code-review/api/history — 歷史 review 清單
|
||||
GET /code-review/ — 前端儀表板
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
code_review_bp = Blueprint("code_review", __name__, url_prefix="/code-review")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# CD Webhook — 部署成功後由 Gitea Action 呼叫
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@code_review_bp.route("/api/internal/trigger", methods=["POST"])
|
||||
def trigger_review():
|
||||
"""
|
||||
接受 Gitea CD pipeline 觸發 Code Review 的 webhook。
|
||||
|
||||
Request body (JSON):
|
||||
commit_sha str 完整 commit SHA
|
||||
changed_files list 變更檔案路徑清單
|
||||
branch str 分支名稱(預設 main)
|
||||
deploy_type str sync | rebuild
|
||||
|
||||
Header:
|
||||
X-Internal-Token 與 INTERNAL_WEBHOOK_TOKEN env 比對
|
||||
"""
|
||||
try:
|
||||
from services.code_review_pipeline_service import (
|
||||
trigger_post_deploy_review,
|
||||
verify_internal_token,
|
||||
)
|
||||
|
||||
# 驗證 token
|
||||
token = request.headers.get("X-Internal-Token", "")
|
||||
if not verify_internal_token(token):
|
||||
logger.warning("[CodeReview] Webhook token 驗證失敗")
|
||||
return jsonify({"ok": False, "error": "Unauthorized"}), 401
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
commit_sha = body.get("commit_sha", "")
|
||||
changed_files = body.get("changed_files", [])
|
||||
branch = body.get("branch", "main")
|
||||
deploy_type = body.get("deploy_type", "sync")
|
||||
|
||||
if not commit_sha:
|
||||
return jsonify({"ok": False, "error": "commit_sha 必填"}), 400
|
||||
|
||||
pipeline_id = trigger_post_deploy_review(
|
||||
commit_sha=commit_sha,
|
||||
changed_files=changed_files,
|
||||
branch=branch,
|
||||
deploy_type=deploy_type,
|
||||
)
|
||||
|
||||
logger.info("[CodeReview] Webhook 觸發成功 pipeline_id=%s", pipeline_id)
|
||||
return jsonify({"ok": True, "pipeline_id": pipeline_id})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[CodeReview] Webhook 處理失敗: %s", e, exc_info=True)
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# 前端 API — 即時狀態 / 歷史
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@code_review_bp.route("/api/status", methods=["GET"])
|
||||
def api_status():
|
||||
"""即時 Pipeline 狀態(前端每 3 秒 polling)"""
|
||||
try:
|
||||
from services.code_review_pipeline_service import get_current_state
|
||||
return jsonify(get_current_state())
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@code_review_bp.route("/api/history", methods=["GET"])
|
||||
def api_history():
|
||||
"""歷史 Code Review 結果清單"""
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", 20)), 50)
|
||||
from services.code_review_pipeline_service import get_history
|
||||
return jsonify(get_history(limit=limit))
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# 前端頁面
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@code_review_bp.route("/", methods=["GET"])
|
||||
def dashboard():
|
||||
return render_template("code_review.html")
|
||||
614
services/code_review_pipeline_service.py
Normal file
614
services/code_review_pipeline_service.py
Normal file
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
services/code_review_pipeline_service.py
|
||||
Post-Deploy AI Agent Code Review Pipeline
|
||||
|
||||
觸發時機:CD 健康檢查通過後,由 Gitea Action webhook 呼叫
|
||||
Pipeline:
|
||||
Step 1 system 讀取變更檔案內容
|
||||
Step 2 Hermes 程式碼掃描(bugs / security / performance)
|
||||
Step 3 OpenClaw 架構品質評估(Gemini 2.5 Flash)
|
||||
Step 4 ElephantAlpha 決策協調(severity 判定 + auto-fix 裁量)
|
||||
Step 5 NemoTron 行動派遣(action_plans 寫入 + AiderHeal 觸發)
|
||||
|
||||
結果輸出:
|
||||
- ai_insights(type='code_review_result')
|
||||
- action_plans(type='code_review_fix')
|
||||
- Telegram 告警(啟動 / 完成 / 錯誤)
|
||||
- 前端 /code-review/ 即時 polling 狀態
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from database.manager import get_session
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Pipeline 全域狀態(供前端 polling)─────────────────────────────────────
|
||||
_current_pipeline: Dict[str, Any] = {}
|
||||
_pipeline_lock = threading.Lock()
|
||||
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||||
REVIEW_MODEL = os.getenv("OPENCLAW_MODEL", "gemini-2.5-flash-preview-05-20")
|
||||
INTERNAL_TOKEN = os.getenv("INTERNAL_WEBHOOK_TOKEN", "")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Pipeline Class
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class CodeReviewPipeline:
|
||||
"""
|
||||
5-Step post-deploy code review pipeline.
|
||||
Call pipeline.run() inside a daemon thread.
|
||||
"""
|
||||
|
||||
def __init__(self, commit_sha: str, changed_files: List[str],
|
||||
branch: str = "main", deploy_type: str = "sync"):
|
||||
self.commit_sha = commit_sha
|
||||
self.branch = branch
|
||||
self.deploy_type = deploy_type
|
||||
self.started_at = datetime.now()
|
||||
self.pipeline_id = f"cr_{commit_sha[:8]}_{self.started_at.strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
# 只 review Python + YAML 檔(跳過靜態資源)
|
||||
self.changed_files = [
|
||||
f for f in changed_files
|
||||
if f.endswith(('.py', '.yaml', '.yml', '.json'))
|
||||
and not f.startswith(('node_modules/', '.git/'))
|
||||
]
|
||||
|
||||
self.state: Dict[str, Any] = {
|
||||
"pipeline_id": self.pipeline_id,
|
||||
"commit_sha": commit_sha,
|
||||
"branch": branch,
|
||||
"changed_files": self.changed_files,
|
||||
"status": "running",
|
||||
"current_step": 0,
|
||||
"total_steps": 5,
|
||||
"steps": [],
|
||||
"findings": [],
|
||||
"severity_summary": {"critical": 0, "high": 0, "medium": 0, "low": 0},
|
||||
"openclaw_report": "",
|
||||
"ea_decision": {},
|
||||
"auto_fix_triggered": False,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": None,
|
||||
"message": "",
|
||||
}
|
||||
self._sync_global()
|
||||
|
||||
# ── State helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _sync_global(self):
|
||||
global _current_pipeline
|
||||
with _pipeline_lock:
|
||||
_current_pipeline = dict(self.state)
|
||||
|
||||
def _step_start(self, num: int, name: str, agent: str):
|
||||
self.state["current_step"] = num
|
||||
self.state["steps"].append({
|
||||
"step": num,
|
||||
"name": name,
|
||||
"agent": agent,
|
||||
"status": "running",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"completed_at": None,
|
||||
"summary": "",
|
||||
})
|
||||
self._sync_global()
|
||||
logger.info("[CodeReview] ▶ Step %d/5 %s (%s)", num, name, agent)
|
||||
|
||||
def _step_done(self, num: int, summary: str, ok: bool = True):
|
||||
for s in self.state["steps"]:
|
||||
if s["step"] == num:
|
||||
s["status"] = "ok" if ok else "error"
|
||||
s["completed_at"] = datetime.now().isoformat()
|
||||
s["summary"] = summary[:300]
|
||||
self._sync_global()
|
||||
logger.info("[CodeReview] %s Step %d — %s", "✓" if ok else "✗", num, summary[:100])
|
||||
|
||||
def _finish(self, status: str, message: str):
|
||||
self.state["status"] = status
|
||||
self.state["completed_at"] = datetime.now().isoformat()
|
||||
self.state["message"] = message
|
||||
self._sync_global()
|
||||
|
||||
# ── Main pipeline ─────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
"""Execute full pipeline. Designed to run in daemon thread."""
|
||||
try:
|
||||
self._notify_start()
|
||||
|
||||
# Step 1 ─ read files
|
||||
self._step_start(1, "讀取變更檔案", "system")
|
||||
file_contents = self._read_changed_files()
|
||||
if not file_contents:
|
||||
self._step_done(1, "無有效 Python/YAML 變更,跳過 Review")
|
||||
self._finish("skipped", "無有效變更檔案")
|
||||
return
|
||||
self._step_done(1, f"讀取 {len(file_contents)} 個檔案")
|
||||
|
||||
# Step 2 ─ Hermes scan
|
||||
self._step_start(2, "Hermes 程式碼掃描", "Hermes")
|
||||
findings = self._hermes_scan(file_contents)
|
||||
cnt = self.state["severity_summary"]
|
||||
self._step_done(2, f"CRITICAL={cnt['critical']} HIGH={cnt['high']} MEDIUM={cnt['medium']} LOW={cnt['low']}")
|
||||
|
||||
# Step 3 ─ OpenClaw assessment
|
||||
self._step_start(3, "OpenClaw 架構品質評估", "OpenClaw")
|
||||
openclaw_report = self._openclaw_assess(file_contents, findings)
|
||||
self.state["openclaw_report"] = openclaw_report
|
||||
self._step_done(3, openclaw_report[:120] if openclaw_report else "(Gemini 未回應)")
|
||||
|
||||
# Step 4 ─ ElephantAlpha orchestration
|
||||
self._step_start(4, "ElephantAlpha 決策協調", "ElephantAlpha")
|
||||
ea = self._ea_orchestrate(findings, openclaw_report)
|
||||
self.state["ea_decision"] = ea
|
||||
self._step_done(4, f"優先度={ea.get('priority','?')} auto_fix={ea.get('auto_fix',False)}")
|
||||
|
||||
# Step 5 ─ NemoTron dispatch
|
||||
self._step_start(5, "NemoTron 行動派遣", "NemoTron")
|
||||
dispatch = self._nemotron_dispatch(ea, findings)
|
||||
self._step_done(5, f"寫入 {dispatch['actions']} 筆 action_plans,自動修復={'是' if dispatch['auto_fix'] else '否'}")
|
||||
|
||||
# Persist + notify
|
||||
self._save_to_db(findings, openclaw_report, ea)
|
||||
self._notify_complete(findings, openclaw_report, ea)
|
||||
self._finish("completed", "Pipeline 執行完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[CodeReview] Pipeline 例外: %s", e, exc_info=True)
|
||||
self._finish("error", str(e)[:200])
|
||||
self._notify_error(str(e))
|
||||
|
||||
# ── Step 1:讀取檔案 ───────────────────────────────────────────────────────
|
||||
|
||||
def _read_changed_files(self) -> Dict[str, str]:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
contents: Dict[str, str] = {}
|
||||
for rel_path in self.changed_files[:8]: # 最多 8 個檔案
|
||||
abs_path = os.path.join(project_root, rel_path)
|
||||
try:
|
||||
with open(abs_path, encoding="utf-8", errors="ignore") as fh:
|
||||
raw = fh.read()
|
||||
contents[rel_path] = raw[:6000] + ("\n... (截斷)" if len(raw) > 6000 else "")
|
||||
except OSError:
|
||||
logger.debug("[CodeReview] 無法讀取 %s(部署路徑不同?)", rel_path)
|
||||
return contents
|
||||
|
||||
# ── Step 2:Hermes 掃描 ───────────────────────────────────────────────────
|
||||
|
||||
def _hermes_scan(self, files: Dict[str, str]) -> List[Dict]:
|
||||
try:
|
||||
from services.ai_provider import ai_provider_service
|
||||
|
||||
# 最多送 4 個檔案給 Hermes,避免 context overflow
|
||||
files_text = "\n\n".join(
|
||||
f"### {name}\n```python\n{content}\n```"
|
||||
for name, content in list(files.items())[:4]
|
||||
)
|
||||
|
||||
prompt = f"""你是資深程式碼審查工程師,請掃描以下程式碼並列出所有問題。
|
||||
|
||||
{files_text}
|
||||
|
||||
輸出格式:純 JSON 陣列,每項包含以下欄位:
|
||||
- severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"
|
||||
- type: "bug" | "security" | "performance" | "maintainability"
|
||||
- file: 檔案名稱
|
||||
- line_hint: 約略行號或函式名稱
|
||||
- description: 問題說明(繁體中文,精簡一句)
|
||||
- suggestion: 修復建議(繁體中文,精簡一句)
|
||||
|
||||
只輸出 JSON 陣列,不含其他文字。無問題時輸出 []"""
|
||||
|
||||
resp = ai_provider_service.generate(
|
||||
prompt=prompt,
|
||||
provider="ollama",
|
||||
model="hermes3:latest",
|
||||
temperature=0.1,
|
||||
timeout=120,
|
||||
)
|
||||
if not (resp and resp.success):
|
||||
logger.warning("[CodeReview] Hermes 未回應: %s", getattr(resp, "error", ""))
|
||||
return []
|
||||
|
||||
match = re.search(r"\[.*\]", resp.content, re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
findings = json.loads(match.group())
|
||||
|
||||
# 更新 severity 計數
|
||||
for f in findings:
|
||||
sev = f.get("severity", "LOW").lower()
|
||||
if sev in self.state["severity_summary"]:
|
||||
self.state["severity_summary"][sev] += 1
|
||||
self.state["findings"] = findings
|
||||
self._sync_global()
|
||||
return findings
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] Hermes 掃描失敗: %s", e)
|
||||
return []
|
||||
|
||||
# ── Step 3:OpenClaw 評估 ──────────────────────────────────────────────────
|
||||
|
||||
def _openclaw_assess(self, files: Dict[str, str], findings: List[Dict]) -> str:
|
||||
if not GEMINI_API_KEY:
|
||||
logger.warning("[CodeReview] GEMINI_API_KEY 未設定,跳過 OpenClaw")
|
||||
return ""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel(
|
||||
model_name=REVIEW_MODEL,
|
||||
generation_config=genai.types.GenerationConfig(
|
||||
temperature=0.3, max_output_tokens=1500,
|
||||
),
|
||||
system_instruction=(
|
||||
"你是 OpenClaw 程式碼品質戰略分析師,以技術主管視角評估部署後程式碼。"
|
||||
"語言:繁體中文。風格:精準、數據導向、可執行建議。"
|
||||
),
|
||||
)
|
||||
sev = self.state["severity_summary"]
|
||||
findings_json = json.dumps(findings[:8], ensure_ascii=False, indent=2)
|
||||
files_list = "\n".join(f"- {k} ({len(v)} 字元)" for k, v in list(files.items())[:5])
|
||||
|
||||
prompt = f"""【部署資訊】Commit {self.commit_sha[:8]} @ {self.branch}
|
||||
【變更檔案】
|
||||
{files_list}
|
||||
|
||||
【Hermes 掃描摘要】CRITICAL={sev['critical']} HIGH={sev['high']} MEDIUM={sev['medium']} LOW={sev['low']}
|
||||
【Hermes 詳細問題】
|
||||
{findings_json}
|
||||
|
||||
請產出程式碼品質評估(使用 HTML <b> 標題,150字以內):
|
||||
|
||||
<b>🔍 整體風險等級</b>(CRITICAL / HIGH / MEDIUM / LOW,一句理由)
|
||||
<b>⚠️ 最需關注問題</b>(TOP 2,具體說明)
|
||||
<b>💡 架構優化方向</b>(1 條長期建議)
|
||||
<b>✅ 本次部署亮點</b>(值得肯定的地方)"""
|
||||
|
||||
resp = model.generate_content(prompt, request_options={"timeout": 90})
|
||||
return resp.text or ""
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] OpenClaw 評估失敗: %s", e)
|
||||
return ""
|
||||
|
||||
# ── Step 4:ElephantAlpha 決策 ─────────────────────────────────────────────
|
||||
|
||||
def _ea_orchestrate(self, findings: List[Dict], openclaw_report: str) -> Dict:
|
||||
sev = self.state["severity_summary"]
|
||||
critical_n = sev["critical"]
|
||||
high_n = sev["high"]
|
||||
|
||||
# 嘗試呼叫 ElephantAlpha 做精細判斷
|
||||
try:
|
||||
from services.elephant_service import elephant_service
|
||||
|
||||
top3 = json.dumps(
|
||||
[f for f in findings if f.get("severity") in ("CRITICAL", "HIGH")][:3],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
prompt = f"""你是 Elephant Alpha,負責協調 Code Review 後的修復決策。
|
||||
|
||||
【部署】commit={self.commit_sha[:8]} branch={self.branch}
|
||||
【問題統計】CRITICAL={critical_n} HIGH={high_n} MEDIUM={sev['medium']} LOW={sev['low']}
|
||||
【Top 問題】{top3}
|
||||
【OpenClaw評估摘要】{openclaw_report[:300]}
|
||||
|
||||
請以 JSON 回答(不含其他文字):
|
||||
{{
|
||||
"priority": "critical|high|medium|low",
|
||||
"auto_fix": true|false,
|
||||
"reasoning": "決策理由(繁體中文,一句話,需含具體數字)",
|
||||
"fix_files": ["需自動修復的檔案(最多3個,只填 CRITICAL/HIGH 問題的檔案)"],
|
||||
"human_review_needed": true|false
|
||||
}}
|
||||
|
||||
規則:
|
||||
- CRITICAL ≥ 1 → priority=critical, auto_fix=true
|
||||
- HIGH ≥ 3 → priority=high, auto_fix=true
|
||||
- HIGH 1-2 → priority=high, auto_fix=false, human_review_needed=true
|
||||
- 其餘 → priority=medium|low, auto_fix=false"""
|
||||
|
||||
resp = elephant_service.generate(
|
||||
prompt=prompt,
|
||||
json_mode=True,
|
||||
temperature=0.1,
|
||||
timeout=60,
|
||||
)
|
||||
if resp.success:
|
||||
return json.loads(resp.content)
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] ElephantAlpha 決策失敗,回退規則: %s", e)
|
||||
|
||||
# 規則 fallback
|
||||
auto_fix = critical_n > 0 or high_n >= 3
|
||||
priority = (
|
||||
"critical" if critical_n > 0 else
|
||||
"high" if high_n > 0 else
|
||||
"medium" if sev["medium"] > 0 else "low"
|
||||
)
|
||||
fix_files = list({
|
||||
f.get("file", "") for f in findings
|
||||
if f.get("severity") in ("CRITICAL", "HIGH") and f.get("file")
|
||||
})[:3]
|
||||
|
||||
return {
|
||||
"priority": priority,
|
||||
"auto_fix": auto_fix,
|
||||
"reasoning": f"規則判斷:CRITICAL={critical_n} HIGH={high_n},{'觸發自動修復' if auto_fix else '需人工審查'}",
|
||||
"fix_files": fix_files,
|
||||
"human_review_needed": not auto_fix and (critical_n + high_n) > 0,
|
||||
}
|
||||
|
||||
# ── Step 5:NemoTron 派遣 ──────────────────────────────────────────────────
|
||||
|
||||
def _nemotron_dispatch(self, ea: Dict, findings: List[Dict]) -> Dict:
|
||||
auto_fix = ea.get("auto_fix", False)
|
||||
fix_files = ea.get("fix_files", [])
|
||||
priority_map = {"critical": 1, "high": 2, "medium": 3, "low": 4}
|
||||
priority_num = priority_map.get(ea.get("priority", "low"), 4)
|
||||
|
||||
actions_created = 0
|
||||
session = get_session()
|
||||
try:
|
||||
# 每個需修復的檔案建立一筆 action_plan
|
||||
for fpath in fix_files[:3]:
|
||||
related = [f for f in findings if f.get("file") == fpath][:3]
|
||||
desc = f"Code Review 修復:{fpath}|{', '.join(f.get('description','')[:40] for f in related)}"
|
||||
session.execute(text("""
|
||||
INSERT INTO action_plans
|
||||
(action_type, description, status, priority, metadata_json, created_at)
|
||||
VALUES
|
||||
('code_review_fix', :desc, :status, :priority, :meta, NOW())
|
||||
"""), {
|
||||
"desc": desc[:500],
|
||||
"status": "auto_pending" if auto_fix else "pending_review",
|
||||
"priority": priority_num,
|
||||
"meta": json.dumps({
|
||||
"pipeline_id": self.pipeline_id,
|
||||
"commit_sha": self.commit_sha,
|
||||
"file": fpath,
|
||||
"auto_fix": auto_fix,
|
||||
"ea_priority": ea.get("priority"),
|
||||
"findings": related,
|
||||
}, ensure_ascii=False),
|
||||
})
|
||||
actions_created += 1
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] action_plans 寫入失敗: %s", e)
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# 觸發 AiderHeal(非阻塞)
|
||||
if auto_fix and fix_files:
|
||||
self.state["auto_fix_triggered"] = True
|
||||
self._sync_global()
|
||||
self._trigger_aider_heal(findings, fix_files)
|
||||
|
||||
return {"actions": actions_created, "auto_fix": auto_fix}
|
||||
|
||||
def _trigger_aider_heal(self, findings: List[Dict], fix_files: List[str]):
|
||||
"""非阻塞觸發 AiderHeal 自動修復"""
|
||||
def _heal_worker():
|
||||
try:
|
||||
from services.aider_heal_executor import execute_code_fix
|
||||
for fpath in fix_files[:2]: # 最多同時修 2 個檔案
|
||||
related = [f for f in findings if f.get("file") == fpath]
|
||||
if not related:
|
||||
continue
|
||||
worst = sorted(related, key=lambda x: {"CRITICAL":0,"HIGH":1,"MEDIUM":2,"LOW":3}.get(x.get("severity","LOW"),3))[0]
|
||||
result = execute_code_fix(
|
||||
error_type=f"code_review_{worst.get('type','bug')}",
|
||||
error_message=worst.get("description", "Code Review 發現問題"),
|
||||
target_file=fpath,
|
||||
context={"suggestion": worst.get("suggestion", ""), "pipeline_id": self.pipeline_id},
|
||||
)
|
||||
logger.info("[CodeReview] AiderHeal %s → %s", fpath, result.get("message", ""))
|
||||
except Exception as e:
|
||||
logger.error("[CodeReview] AiderHeal 觸發失敗: %s", e)
|
||||
|
||||
t = threading.Thread(target=_heal_worker, daemon=True, name=f"aider-heal-{self.commit_sha[:8]}")
|
||||
t.start()
|
||||
|
||||
# ── DB 持久化 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _save_to_db(self, findings: List[Dict], openclaw_report: str, ea: Dict):
|
||||
session = get_session()
|
||||
try:
|
||||
session.execute(text("""
|
||||
INSERT INTO ai_insights
|
||||
(insight_type, content, confidence, created_by,
|
||||
status, metadata_json, period, created_at)
|
||||
VALUES
|
||||
('code_review_result', :content, :conf, 'code_review_pipeline',
|
||||
'active', :meta, :period, NOW())
|
||||
"""), {
|
||||
"content": json.dumps({
|
||||
"findings": findings,
|
||||
"openclaw_report": openclaw_report,
|
||||
"ea_decision": ea,
|
||||
"severity_summary": self.state["severity_summary"],
|
||||
}, ensure_ascii=False)[:8000],
|
||||
"conf": 0.90,
|
||||
"meta": json.dumps({
|
||||
"pipeline_id": self.pipeline_id,
|
||||
"commit_sha": self.commit_sha,
|
||||
"branch": self.branch,
|
||||
"changed_files": self.changed_files,
|
||||
"auto_fix_triggered": self.state["auto_fix_triggered"],
|
||||
}, ensure_ascii=False),
|
||||
"period": datetime.now().strftime("%Y-%m-%d"),
|
||||
})
|
||||
session.commit()
|
||||
logger.info("[CodeReview] ai_insights 寫入成功 pipeline=%s", self.pipeline_id)
|
||||
except Exception as e:
|
||||
logger.error("[CodeReview] DB 寫入失敗: %s", e)
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ── Telegram 通知 ─────────────────────────────────────────────────────────
|
||||
|
||||
def _notify_start(self):
|
||||
try:
|
||||
from services.telegram_templates import _send_telegram_raw
|
||||
files_list = "\n".join(f" • {f}" for f in self.changed_files[:5])
|
||||
if len(self.changed_files) > 5:
|
||||
files_list += f"\n (+{len(self.changed_files)-5} 個)"
|
||||
_send_telegram_raw(
|
||||
f"🔍 <b>Code Review 啟動</b>\n"
|
||||
f"══════════════════════════\n"
|
||||
f"📦 Commit <code>{self.commit_sha[:8]}</code> 🌿 {self.branch}\n"
|
||||
f"📝 變更檔案:\n{files_list}\n"
|
||||
f"══════════════════════════\n"
|
||||
f"🤖 <i>Hermes → OpenClaw → Elephant Alpha → NemoTron</i>\n"
|
||||
f"📊 即時進度:https://mo.wooo.work/code-review/"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] 啟動通知失敗: %s", e)
|
||||
|
||||
def _notify_complete(self, findings: List[Dict], openclaw_report: str, ea: Dict):
|
||||
try:
|
||||
from services.telegram_templates import _send_telegram_raw
|
||||
sev = self.state["severity_summary"]
|
||||
priority = ea.get("priority", "medium")
|
||||
auto_fix = ea.get("auto_fix", False)
|
||||
|
||||
icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🟢"}.get(priority, "🟡")
|
||||
top_issues = [f for f in findings if f.get("severity") in ("CRITICAL", "HIGH")][:3]
|
||||
issues_lines = "\n".join(
|
||||
f" {'🔴' if f['severity']=='CRITICAL' else '🟠'} [{f['severity']}] {f.get('description','')} — <i>{f.get('file','')}</i>"
|
||||
for f in top_issues
|
||||
) or " ✅ 無高風險問題"
|
||||
|
||||
msg = (
|
||||
f"{icon} <b>Code Review 完成</b> · <code>{self.commit_sha[:8]}</code>\n"
|
||||
f"══════════════════════════\n"
|
||||
f"🔴 CRITICAL <b>{sev['critical']}</b> "
|
||||
f"🟠 HIGH <b>{sev['high']}</b> "
|
||||
f"🟡 MEDIUM <b>{sev['medium']}</b> "
|
||||
f"🟢 LOW <b>{sev['low']}</b>\n"
|
||||
f"══════════════════════════\n"
|
||||
f"<b>⚠️ 主要問題</b>\n{issues_lines}\n"
|
||||
)
|
||||
if openclaw_report:
|
||||
msg += f"\n{openclaw_report[:400]}\n"
|
||||
|
||||
fix_status = "🔧 已觸發自動修復(AiderHeal)" if auto_fix else (
|
||||
"👁 需人工審查" if ea.get("human_review_needed") else "✅ 無需修復動作"
|
||||
)
|
||||
msg += (
|
||||
f"══════════════════════════\n"
|
||||
f"🤖 Elephant Alpha:<b>{priority.upper()}</b> {fix_status}\n"
|
||||
f"📊 完整報告:https://mo.wooo.work/code-review/"
|
||||
)
|
||||
_send_telegram_raw(msg)
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] 完成通知失敗: %s", e)
|
||||
|
||||
def _notify_error(self, error: str):
|
||||
try:
|
||||
from services.telegram_templates import _send_telegram_raw
|
||||
_send_telegram_raw(
|
||||
f"🚨 <b>Code Review Pipeline 失敗</b>\n"
|
||||
f"Commit:<code>{self.commit_sha[:8]}</code> @ {self.branch}\n"
|
||||
f"錯誤:{error[:200]}\n"
|
||||
f"📊 查看:https://mo.wooo.work/code-review/"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 公開 API
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def trigger_post_deploy_review(
|
||||
commit_sha: str,
|
||||
changed_files: List[str],
|
||||
branch: str = "main",
|
||||
deploy_type: str = "sync",
|
||||
) -> str:
|
||||
"""
|
||||
啟動 Pipeline(後台 daemon thread)。
|
||||
回傳 pipeline_id。由 routes/code_review_routes.py 的 webhook 端點呼叫。
|
||||
"""
|
||||
pipeline = CodeReviewPipeline(commit_sha, changed_files, branch, deploy_type)
|
||||
t = threading.Thread(
|
||||
target=pipeline.run,
|
||||
daemon=True,
|
||||
name=f"code-review-{commit_sha[:8]}",
|
||||
)
|
||||
t.start()
|
||||
logger.info("[CodeReview] 已派發 pipeline=%s files=%d", pipeline.pipeline_id, len(pipeline.changed_files))
|
||||
return pipeline.pipeline_id
|
||||
|
||||
|
||||
def get_current_state() -> Dict[str, Any]:
|
||||
"""前端 polling 用:取得目前 pipeline 即時狀態"""
|
||||
with _pipeline_lock:
|
||||
return dict(_current_pipeline)
|
||||
|
||||
|
||||
def get_history(limit: int = 20) -> List[Dict]:
|
||||
"""取得 ai_insights 中歷史 code_review_result 記錄"""
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(text("""
|
||||
SELECT id, content, confidence, metadata_json, created_at, status
|
||||
FROM ai_insights
|
||||
WHERE insight_type = 'code_review_result'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :lim
|
||||
"""), {"lim": limit}).fetchall()
|
||||
|
||||
results = []
|
||||
for r in rows:
|
||||
meta, content = {}, {}
|
||||
try:
|
||||
meta = json.loads(r[3]) if r[3] else {}
|
||||
content = json.loads(r[1]) if r[1] else {}
|
||||
except Exception:
|
||||
pass
|
||||
sev = content.get("severity_summary", {})
|
||||
results.append({
|
||||
"id": r[0],
|
||||
"pipeline_id": meta.get("pipeline_id", ""),
|
||||
"commit_sha": meta.get("commit_sha", "")[:8],
|
||||
"branch": meta.get("branch", ""),
|
||||
"changed_files": meta.get("changed_files", []),
|
||||
"severity_summary": sev,
|
||||
"total_issues": sum(sev.values()),
|
||||
"auto_fix": meta.get("auto_fix_triggered", False),
|
||||
"ea_decision": content.get("ea_decision", {}),
|
||||
"created_at": r[4].isoformat() if r[4] else "",
|
||||
"status": r[5] or "active",
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.warning("[CodeReview] 歷史讀取失敗: %s", e)
|
||||
return []
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def verify_internal_token(request_token: str) -> bool:
|
||||
"""驗證 CD webhook 來源 token。未設定 env 時直接放行(dev 環境)"""
|
||||
if not INTERNAL_TOKEN:
|
||||
return True
|
||||
return request_token == INTERNAL_TOKEN
|
||||
438
templates/code_review.html
Normal file
438
templates/code_review.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EwoooC — AI Code Review Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--red: #f85149;
|
||||
--orange: #d29922;
|
||||
--yellow: #e3b341;
|
||||
--green: #3fb950;
|
||||
--blue: #58a6ff;
|
||||
--purple: #bc8cff;
|
||||
--accent: #e94560;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', sans-serif; font-size: 14px; }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────── */
|
||||
.topbar { background: var(--panel); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; }
|
||||
.topbar h1 { font-size: 18px; font-weight: 700; color: var(--accent); }
|
||||
.topbar .badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; background: #21262d; color: var(--muted); }
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite; margin-left: auto; }
|
||||
.live-dot.idle { background: var(--muted); animation: none; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.layout { display: grid; grid-template-columns: 340px 1fr; gap: 0; height: calc(100vh - 49px); overflow: hidden; }
|
||||
.sidebar { background: var(--panel); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px; }
|
||||
.main { overflow-y: auto; padding: 16px; }
|
||||
|
||||
/* ── Card ───────────────────────────────────────────── */
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||||
.card-header { padding: 10px 14px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.card-body { padding: 14px; }
|
||||
|
||||
/* ── Pipeline Steps ──────────────────────────────────── */
|
||||
.pipeline { display: flex; flex-direction: column; gap: 6px; }
|
||||
.step { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border); background: #0d1117; transition: border-color .3s; }
|
||||
.step.running { border-color: var(--blue); background: rgba(88,166,255,.07); }
|
||||
.step.ok { border-color: var(--green); background: rgba(63,185,80,.06); }
|
||||
.step.error { border-color: var(--red); background: rgba(248,81,73,.07); }
|
||||
.step-num { width: 22px; height: 22px; border-radius: 50%; background: #21262d; font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.step.ok .step-num { background: var(--green); color: #000; }
|
||||
.step.running .step-num { background: var(--blue); color: #000; }
|
||||
.step.error .step-num { background: var(--red); color: #fff; }
|
||||
.step-info { flex: 1; min-width: 0; }
|
||||
.step-name { font-weight: 600; font-size: 13px; }
|
||||
.step-agent { font-size: 11px; color: var(--purple); }
|
||||
.step-summary { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.step-icon { font-size: 16px; }
|
||||
.spinner { display: inline-block; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Severity Badges ─────────────────────────────────── */
|
||||
.sev-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 8px; }
|
||||
.sev-cell { text-align: center; padding: 10px 6px; border-radius: 6px; border: 1px solid var(--border); }
|
||||
.sev-cell .num { font-size: 28px; font-weight: 700; line-height: 1; }
|
||||
.sev-cell .lbl { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
.sev-critical { border-color: var(--red)!important; } .sev-critical .num { color: var(--red); }
|
||||
.sev-high { border-color: var(--orange)!important; } .sev-high .num { color: var(--orange); }
|
||||
.sev-medium { border-color: var(--yellow)!important; } .sev-medium .num { color: var(--yellow); }
|
||||
.sev-low { border-color: var(--green)!important; } .sev-low .num { color: var(--green); }
|
||||
|
||||
/* ── Findings Table ──────────────────────────────────── */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { padding: 8px 10px; text-align: left; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid #21262d; vertical-align: top; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,.02); }
|
||||
.badge-sev { display: inline-block; padding: 2px 7px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
||||
.badge-CRITICAL { background: rgba(248,81,73,.2); color: var(--red); }
|
||||
.badge-HIGH { background: rgba(210,153,34,.2); color: var(--orange); }
|
||||
.badge-MEDIUM { background: rgba(227,179,65,.2); color: var(--yellow); }
|
||||
.badge-LOW { background: rgba(63,185,80,.2); color: var(--green); }
|
||||
.badge-type { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 10px; background: #21262d; color: var(--muted); }
|
||||
code { background: #21262d; padding: 1px 5px; border-radius: 4px; font-size: 12px; color: var(--blue); }
|
||||
|
||||
/* ── OpenClaw Report ─────────────────────────────────── */
|
||||
.report-box { background: #0d1117; border: 1px solid var(--border); border-radius: 6px; padding: 12px; font-size: 13px; line-height: 1.6; }
|
||||
.report-box b { color: var(--text); }
|
||||
|
||||
/* ── EA Decision ─────────────────────────────────────── */
|
||||
.ea-box { padding: 12px 14px; border-radius: 6px; border-left: 4px solid var(--blue); background: rgba(88,166,255,.06); }
|
||||
.ea-box.critical { border-left-color: var(--red); background: rgba(248,81,73,.07); }
|
||||
.ea-box.high { border-left-color: var(--orange); background: rgba(210,153,34,.07); }
|
||||
.ea-box.medium { border-left-color: var(--yellow); }
|
||||
.ea-box.low { border-left-color: var(--green); }
|
||||
.ea-priority { font-size: 20px; font-weight: 700; }
|
||||
.ea-reasoning { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
.ea-fix { margin-top: 8px; font-size: 12px; }
|
||||
|
||||
/* ── History ─────────────────────────────────────────── */
|
||||
.hist-item { padding: 10px; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; cursor: pointer; transition: border-color .2s; }
|
||||
.hist-item:hover { border-color: var(--blue); }
|
||||
.hist-sha { font-family: monospace; font-size: 12px; color: var(--blue); }
|
||||
.hist-meta { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
||||
.hist-sev { display: flex; gap: 6px; margin-top: 5px; }
|
||||
.hist-sev span { font-size: 11px; padding: 1px 6px; border-radius: 8px; }
|
||||
|
||||
/* ── Status Bar ──────────────────────────────────────── */
|
||||
#statusBar { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; display: none; }
|
||||
#statusBar.running { background: rgba(88,166,255,.1); border: 1px solid var(--blue); }
|
||||
#statusBar.completed { background: rgba(63,185,80,.1); border: 1px solid var(--green); }
|
||||
#statusBar.error { background: rgba(248,81,73,.1); border: 1px solid var(--red); }
|
||||
#statusBar.skipped { background: rgba(139,148,158,.1); border: 1px solid var(--border); }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────── */
|
||||
.empty { text-align: center; padding: 40px; color: var(--muted); }
|
||||
.empty .icon { font-size: 40px; margin-bottom: 10px; }
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 12px; }
|
||||
.tab { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--muted); border: 1px solid transparent; }
|
||||
.tab.active { background: #21262d; color: var(--text); border-color: var(--border); }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="topbar">
|
||||
<span>🔍</span>
|
||||
<h1>AI Code Review</h1>
|
||||
<span class="badge">EwoooC · Post-Deploy Pipeline</span>
|
||||
<div id="liveDot" class="live-dot idle" title="Pipeline 狀態"></div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
<!-- ── Sidebar ─────────────────────────────────────────────────── -->
|
||||
<div class="sidebar">
|
||||
|
||||
<!-- Pipeline Steps -->
|
||||
<div class="card">
|
||||
<div class="card-header">🤖 Pipeline 進度
|
||||
<span id="pipelineId" style="font-size:11px;color:var(--muted);margin-left:auto;font-family:monospace"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="pipeline" id="stepsContainer">
|
||||
<div class="step" id="step-1"><div class="step-num">1</div><div class="step-info"><div class="step-name">讀取變更檔案</div><div class="step-agent">system</div></div></div>
|
||||
<div class="step" id="step-2"><div class="step-num">2</div><div class="step-info"><div class="step-name">Hermes 程式碼掃描</div><div class="step-agent">Hermes · hermes3:latest</div></div></div>
|
||||
<div class="step" id="step-3"><div class="step-num">3</div><div class="step-info"><div class="step-name">OpenClaw 架構評估</div><div class="step-agent">OpenClaw · Gemini 2.5</div></div></div>
|
||||
<div class="step" id="step-4"><div class="step-num">4</div><div class="step-info"><div class="step-name">Elephant Alpha 決策</div><div class="step-agent">Elephant Alpha · 100B</div></div></div>
|
||||
<div class="step" id="step-5"><div class="step-num">5</div><div class="step-info"><div class="step-name">NemoTron 行動派遣</div><div class="step-agent">NemoTron · Dispatcher</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Severity Summary -->
|
||||
<div class="card">
|
||||
<div class="card-header">📊 問題嚴重度分佈</div>
|
||||
<div class="card-body">
|
||||
<div class="sev-grid">
|
||||
<div class="sev-cell sev-critical"><div class="num" id="cnt-critical">—</div><div class="lbl">🔴 CRITICAL</div></div>
|
||||
<div class="sev-cell sev-high"><div class="num" id="cnt-high">—</div><div class="lbl">🟠 HIGH</div></div>
|
||||
<div class="sev-cell sev-medium"><div class="num" id="cnt-medium">—</div><div class="lbl">🟡 MEDIUM</div></div>
|
||||
<div class="sev-cell sev-low"><div class="num" id="cnt-low">—</div><div class="lbl">🟢 LOW</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commit Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">📦 本次部署資訊</div>
|
||||
<div class="card-body" id="commitInfo" style="font-size:13px;color:var(--muted);line-height:1.8;">
|
||||
<span style="color:var(--muted)">等待下次部署觸發...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="card">
|
||||
<div class="card-header">🕐 歷史記錄</div>
|
||||
<div class="card-body" style="padding:8px;" id="historyList">
|
||||
<div class="empty"><div class="icon">📋</div><div>載入中...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Main Content ─────────────────────────────────────────────── -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div id="statusBar"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('findings')">⚠️ 問題清單</div>
|
||||
<div class="tab" onclick="switchTab('openclaw')">💡 OpenClaw 評估</div>
|
||||
<div class="tab" onclick="switchTab('ea')">🤖 Elephant Alpha 決策</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Findings -->
|
||||
<div class="tab-pane active" id="tab-findings">
|
||||
<div class="card">
|
||||
<div class="card-header">⚠️ 問題清單
|
||||
<span id="findingsCount" style="font-size:11px;color:var(--muted);margin-left:auto"></span>
|
||||
</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<div id="findingsTable">
|
||||
<div class="empty"><div class="icon">🔍</div><div>等待 Code Review 完成...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: OpenClaw -->
|
||||
<div class="tab-pane" id="tab-openclaw">
|
||||
<div class="card">
|
||||
<div class="card-header">💡 OpenClaw 架構品質評估</div>
|
||||
<div class="card-body">
|
||||
<div class="report-box" id="openclawReport">
|
||||
<span style="color:var(--muted)">等待 OpenClaw 分析完成...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: EA Decision -->
|
||||
<div class="tab-pane" id="tab-ea">
|
||||
<div class="card">
|
||||
<div class="card-header">🤖 Elephant Alpha 決策協調結果</div>
|
||||
<div class="card-body">
|
||||
<div id="eaDecision">
|
||||
<span style="color:var(--muted)">等待 Elephant Alpha 決策...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
||||
let _polling = null;
|
||||
let _lastPipelineId = null;
|
||||
|
||||
// ── Tab switching ──────────────────────────────────────────────────
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', ['findings','openclaw','ea'][i] === name));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
}
|
||||
|
||||
// ── Pipeline step rendering ───────────────────────────────────────
|
||||
function renderSteps(steps, currentStep) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const el = document.getElementById('step-' + i);
|
||||
if (!el) continue;
|
||||
const info = steps.find(s => s.step === i);
|
||||
el.className = 'step';
|
||||
const numEl = el.querySelector('.step-num');
|
||||
const summEl = el.querySelector('.step-summary') || (() => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'step-summary';
|
||||
el.querySelector('.step-info').appendChild(d);
|
||||
return d;
|
||||
})();
|
||||
|
||||
if (info) {
|
||||
if (info.status === 'ok') { el.classList.add('ok'); numEl.textContent = '✓'; }
|
||||
else if (info.status === 'error') { el.classList.add('error'); numEl.textContent = '✗'; }
|
||||
else if (info.status === 'running') { el.classList.add('running'); numEl.innerHTML = '<span class="spinner">⟳</span>'; }
|
||||
summEl.textContent = info.summary || '';
|
||||
} else if (i === currentStep) {
|
||||
el.classList.add('running');
|
||||
numEl.innerHTML = '<span class="spinner">⟳</span>';
|
||||
} else {
|
||||
numEl.textContent = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Severity counters ─────────────────────────────────────────────
|
||||
function renderSeverity(sev) {
|
||||
['critical','high','medium','low'].forEach(k => {
|
||||
const el = document.getElementById('cnt-' + k);
|
||||
if (el) el.textContent = (sev && sev[k] !== undefined) ? sev[k] : '0';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Findings table ────────────────────────────────────────────────
|
||||
function renderFindings(findings) {
|
||||
const el = document.getElementById('findingsTable');
|
||||
document.getElementById('findingsCount').textContent = findings.length ? `共 ${findings.length} 項` : '';
|
||||
if (!findings.length) {
|
||||
el.innerHTML = '<div class="empty"><div class="icon">✅</div><div>無發現問題</div></div>';
|
||||
return;
|
||||
}
|
||||
const sorted = [...findings].sort((a,b) => (SEV_ORDER[a.severity]||3) - (SEV_ORDER[b.severity]||3));
|
||||
el.innerHTML = `<table>
|
||||
<thead><tr><th>嚴重度</th><th>類型</th><th>檔案 / 位置</th><th>問題說明</th><th>修復建議</th></tr></thead>
|
||||
<tbody>${sorted.map(f => `
|
||||
<tr>
|
||||
<td><span class="badge-sev badge-${f.severity}">${f.severity}</span></td>
|
||||
<td><span class="badge-type">${f.type||'?'}</span></td>
|
||||
<td><code>${(f.file||'').split('/').pop()}</code><br><span style="font-size:11px;color:var(--muted)">${f.line_hint||''}</span></td>
|
||||
<td>${escHtml(f.description||'')}</td>
|
||||
<td style="color:var(--muted)">${escHtml(f.suggestion||'')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
// ── OpenClaw report ───────────────────────────────────────────────
|
||||
function renderOpenClaw(html) {
|
||||
document.getElementById('openclawReport').innerHTML = html || '<span style="color:var(--muted)">(未取得)</span>';
|
||||
}
|
||||
|
||||
// ── EA Decision ───────────────────────────────────────────────────
|
||||
function renderEA(ea, autoFix) {
|
||||
if (!ea || !ea.priority) {
|
||||
document.getElementById('eaDecision').innerHTML = '<span style="color:var(--muted)">等待決策...</span>';
|
||||
return;
|
||||
}
|
||||
const priColors = { critical: 'var(--red)', high: 'var(--orange)', medium: 'var(--yellow)', low: 'var(--green)' };
|
||||
const col = priColors[ea.priority] || 'var(--blue)';
|
||||
const fixFiles = (ea.fix_files||[]).map(f=>`<code>${f}</code>`).join(' ');
|
||||
document.getElementById('eaDecision').innerHTML = `
|
||||
<div class="ea-box ${ea.priority}">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="ea-priority" style="color:${col}">${ea.priority.toUpperCase()}</div>
|
||||
<div>${autoFix ? '🔧 <b>自動修復已觸發(AiderHeal)</b>' : (ea.human_review_needed ? '👁 <b>需人工審查</b>' : '✅ <b>無需修復</b>')}</div>
|
||||
</div>
|
||||
<div class="ea-reasoning">${escHtml(ea.reasoning||'')}</div>
|
||||
${fixFiles ? `<div class="ea-fix">修復範圍:${fixFiles}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Commit info ───────────────────────────────────────────────────
|
||||
function renderCommitInfo(state) {
|
||||
const el = document.getElementById('commitInfo');
|
||||
if (!state.commit_sha) return;
|
||||
const files = (state.changed_files||[]).slice(0,5).map(f=>`<code>${f.split('/').pop()}</code>`).join(' ');
|
||||
const more = (state.changed_files||[]).length > 5 ? `<span style="color:var(--muted)">+${state.changed_files.length-5}</span>` : '';
|
||||
el.innerHTML = `
|
||||
<div><b>Commit</b> <code>${state.commit_sha.slice(0,8)}</code></div>
|
||||
<div><b>Branch</b> <code>${state.branch||'?'}</code></div>
|
||||
<div><b>模式</b> ${state.deploy_type||'sync'}</div>
|
||||
<div><b>變更</b> ${files} ${more}</div>`;
|
||||
}
|
||||
|
||||
// ── Status bar ────────────────────────────────────────────────────
|
||||
function renderStatusBar(state) {
|
||||
const el = document.getElementById('statusBar');
|
||||
const dot = document.getElementById('liveDot');
|
||||
if (!state.status) { el.style.display='none'; return; }
|
||||
|
||||
el.style.display = 'block';
|
||||
el.className = state.status;
|
||||
const msgs = {
|
||||
running: `⟳ <b>Pipeline 執行中</b> — Step ${state.current_step}/5`,
|
||||
completed: `✅ <b>Code Review 完成</b> — ${state.message||''}`,
|
||||
error: `❌ <b>Pipeline 失敗</b> — ${escHtml(state.message||'')}`,
|
||||
skipped: `⏭ <b>已略過</b> — ${escHtml(state.message||'')}`,
|
||||
};
|
||||
el.innerHTML = msgs[state.status] || '';
|
||||
|
||||
dot.className = 'live-dot' + (state.status === 'running' ? '' : ' idle');
|
||||
}
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────
|
||||
function renderHistory(items) {
|
||||
const el = document.getElementById('historyList');
|
||||
if (!items.length) { el.innerHTML = '<div class="empty"><div>尚無歷史記錄</div></div>'; return; }
|
||||
el.innerHTML = items.map(h => {
|
||||
const sev = h.severity_summary || {};
|
||||
return `<div class="hist-item">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span class="hist-sha">${h.commit_sha}</span>
|
||||
<span style="font-size:11px;color:var(--muted)">${h.created_at.slice(0,16).replace('T',' ')}</span>
|
||||
</div>
|
||||
<div class="hist-meta">🌿 ${h.branch} • ${(h.changed_files||[]).length} 檔案${h.auto_fix?' • 🔧 已自動修復':''}</div>
|
||||
<div class="hist-sev">
|
||||
${sev.critical?`<span style="background:rgba(248,81,73,.2);color:var(--red)">🔴 ${sev.critical}</span>`:''}
|
||||
${sev.high?`<span style="background:rgba(210,153,34,.2);color:var(--orange)">🟠 ${sev.high}</span>`:''}
|
||||
${sev.medium?`<span style="background:rgba(227,179,65,.2);color:var(--yellow)">🟡 ${sev.medium}</span>`:''}
|
||||
${sev.low?`<span style="background:rgba(63,185,80,.2);color:var(--green)">🟢 ${sev.low}</span>`:''}
|
||||
${!h.total_issues?`<span style="color:var(--green)">✅ 無問題</span>`:''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Main polling loop ─────────────────────────────────────────────
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch('/code-review/api/status');
|
||||
const state = await r.json();
|
||||
|
||||
if (state.pipeline_id !== _lastPipelineId && state.pipeline_id) {
|
||||
_lastPipelineId = state.pipeline_id;
|
||||
}
|
||||
|
||||
renderStatusBar(state);
|
||||
renderSteps(state.steps||[], state.current_step||0);
|
||||
renderSeverity(state.severity_summary);
|
||||
renderFindings(state.findings||[]);
|
||||
renderOpenClaw(state.openclaw_report);
|
||||
renderEA(state.ea_decision, state.auto_fix_triggered);
|
||||
renderCommitInfo(state);
|
||||
document.getElementById('pipelineId').textContent = (state.pipeline_id||'').slice(-14);
|
||||
|
||||
// 每 3s 輪詢(running)/ 30s(idle)
|
||||
const interval = state.status === 'running' ? 3000 : 30000;
|
||||
_polling = setTimeout(poll, interval);
|
||||
} catch(e) {
|
||||
_polling = setTimeout(poll, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const r = await fetch('/code-review/api/history?limit=15');
|
||||
renderHistory(await r.json());
|
||||
} catch(e) {
|
||||
document.getElementById('historyList').innerHTML = '<div class="empty"><div>載入失敗</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
poll();
|
||||
loadHistory();
|
||||
setInterval(loadHistory, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user