feat(flywheel): surface ai automation and code review
This commit is contained in:
163
.gitea/workflows/code-review.yaml
Normal file
163
.gitea/workflows/code-review.yaml
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Code Review
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'k8s/**'
|
||||
- 'ops/**'
|
||||
- 'scripts/**'
|
||||
- '.gitea/workflows/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: code-review-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REPORT_URL: https://mo.wooo.work/code-review/
|
||||
GITEA_ACTIONS_URL: http://192.168.0.110:3001/wooo/awoooi/actions
|
||||
|
||||
jobs:
|
||||
ai-code-review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Prepare Review Context
|
||||
id: ctx
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
|
||||
if [ -z "$BRANCH" ] || [ "$BRANCH" = "$GITHUB_REF" ]; then
|
||||
BRANCH="main"
|
||||
fi
|
||||
COMMIT_MSG="$(git log -1 --pretty=%s | head -c 120)"
|
||||
BASE="${BASE_SHA:-}"
|
||||
if [ -n "$BASE" ] && [ "$BASE" != "0000000000000000000000000000000000000000" ]; then
|
||||
git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1 || git fetch --no-tags origin "$BASE" --depth=1 || true
|
||||
fi
|
||||
|
||||
if [ -n "$BASE" ] && git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
RANGE="$BASE..$GITHUB_SHA"
|
||||
elif git rev-parse --verify "${GITHUB_SHA}^" >/dev/null 2>&1; then
|
||||
BASE="${GITHUB_SHA}^"
|
||||
RANGE="${GITHUB_SHA}^..$GITHUB_SHA"
|
||||
else
|
||||
BASE=""
|
||||
RANGE="$GITHUB_SHA"
|
||||
fi
|
||||
|
||||
FILES="$(git diff --name-only "$RANGE" || git show --pretty= --name-only "$GITHUB_SHA")"
|
||||
if [ -z "$FILES" ]; then
|
||||
FILES="(no files reported)"
|
||||
fi
|
||||
FILE_COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)"
|
||||
FILES_DISPLAY="$(printf '%s\n' "$FILES" | head -6 | sed 's/^/• /')"
|
||||
if [ "$FILE_COUNT" -gt 6 ]; then
|
||||
FILES_DISPLAY="$(printf '%s\n• ... and %s more' "$FILES_DISPLAY" "$((FILE_COUNT - 6))")"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
echo "branch=$BRANCH"
|
||||
echo "base_sha=$BASE"
|
||||
echo "file_count=$FILE_COUNT"
|
||||
echo "commit_msg<<EOF"
|
||||
printf '%s\n' "$COMMIT_MSG"
|
||||
echo "EOF"
|
||||
echo "files_display<<EOF"
|
||||
printf '%s\n' "$FILES_DISPLAY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify Code Review Start
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
SHORT_SHA: ${{ steps.ctx.outputs.short_sha }}
|
||||
BRANCH: ${{ steps.ctx.outputs.branch }}
|
||||
COMMIT_MSG: ${{ steps.ctx.outputs.commit_msg }}
|
||||
FILES_DISPLAY: ${{ steps.ctx.outputs.files_display }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram secret missing; skip start notification"
|
||||
exit 0
|
||||
fi
|
||||
html_escape() { sed 's/&/\&/g; s/</\</g; s/>/\>/g'; }
|
||||
COMMIT_ESC="$(printf '%s' "$COMMIT_MSG" | html_escape)"
|
||||
FILES_ESC="$(printf '%s\n' "$FILES_DISPLAY" | html_escape)"
|
||||
MSG="$(printf '🔍 <b>Code Review 啟動</b>\n──────────────────────\n📦 Commit <code>%s</code> 🌿 <code>%s</code>\n📝 <code>%s</code>\n📁 <b>變更檔案:</b>\n%s\n──────────────────────\n🤖 <b>Hermes → OpenClaw → Elephant Alpha → NemoTron</b>\n📊 即時進度:<a href=\"%s\">%s</a>' "$SHORT_SHA" "$BRANCH" "$COMMIT_ESC" "$FILES_ESC" "$REPORT_URL" "$REPORT_URL")"
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "$TG_CHAT_ID" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML",disable_web_page_preview:true}')" \
|
||||
>/dev/null
|
||||
|
||||
- name: Run Deterministic Review
|
||||
env:
|
||||
BASE_SHA: ${{ steps.ctx.outputs.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/ci_code_review.py \
|
||||
--base "${BASE_SHA:-}" \
|
||||
--head "$GITHUB_SHA" \
|
||||
--repo "." \
|
||||
--output /tmp/code-review-report.json
|
||||
jq . /tmp/code-review-report.json
|
||||
|
||||
- name: Notify Code Review Completion
|
||||
if: always()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
SHORT_SHA: ${{ steps.ctx.outputs.short_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram secret missing; skip completion notification"
|
||||
exit 0
|
||||
fi
|
||||
REPORT=/tmp/code-review-report.json
|
||||
if [ ! -s "$REPORT" ]; then
|
||||
cat > "$REPORT" <<'JSON'
|
||||
{"counts":{"critical":0,"high":0,"medium":1,"low":0},"risk":"MEDIUM","summary":"Code Review workflow 未產生報告,需查看 Gitea Actions 日誌。","action":"查看 workflow logs","top_issue":"報告產生失敗","agents":["Hermes","OpenClaw","ElephantAlpha","NemoTron"]}
|
||||
JSON
|
||||
fi
|
||||
CRITICAL="$(jq -r '.counts.critical' "$REPORT")"
|
||||
HIGH="$(jq -r '.counts.high' "$REPORT")"
|
||||
MEDIUM="$(jq -r '.counts.medium' "$REPORT")"
|
||||
LOW="$(jq -r '.counts.low' "$REPORT")"
|
||||
RISK="$(jq -r '.risk' "$REPORT")"
|
||||
SUMMARY="$(jq -r '.summary' "$REPORT")"
|
||||
ACTION="$(jq -r '.action' "$REPORT")"
|
||||
TOP_ISSUE="$(jq -r '.top_issue' "$REPORT")"
|
||||
|
||||
if [ "$RISK" = "LOW" ]; then
|
||||
STATUS="🟢"
|
||||
ISSUE_LINE="✅ 無高風險問題"
|
||||
elif [ "$RISK" = "MEDIUM" ]; then
|
||||
STATUS="🟡"
|
||||
ISSUE_LINE="⚠️ 有中風險註記"
|
||||
else
|
||||
STATUS="🔴"
|
||||
ISSUE_LINE="🚨 需人工複核"
|
||||
fi
|
||||
|
||||
html_escape() { sed 's/&/\&/g; s/</\</g; s/>/\>/g'; }
|
||||
SUMMARY_ESC="$(printf '%s' "$SUMMARY" | html_escape)"
|
||||
ACTION_ESC="$(printf '%s' "$ACTION" | html_escape)"
|
||||
TOP_ESC="$(printf '%s' "$TOP_ISSUE" | html_escape)"
|
||||
|
||||
MSG="$(printf '%s <b>Code Review 完成・%s</b>\n──────────────────────\n🔴 CRITICAL <code>%s</code> 🟠 HIGH <code>%s</code> 🟡 MEDIUM <code>%s</code> 🟢 LOW <code>%s</code>\n──────────────────────\n⚠️ <b>主要問題</b>\n%s\n\n🔍 <b>整體風險等級</b>\n%s:%s\n\n⚠️ <b>最高關注問題</b>\n1. %s\n──────────────────────\n🤖 Elephant Alpha:<b>%s</b> ✅ %s\n📊 完整報告:<a href=\"%s\">%s</a>' "$STATUS" "$SHORT_SHA" "$CRITICAL" "$HIGH" "$MEDIUM" "$LOW" "$ISSUE_LINE" "$RISK" "$SUMMARY_ESC" "$TOP_ESC" "$RISK" "$ACTION_ESC" "$REPORT_URL" "$REPORT_URL")"
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "$TG_CHAT_ID" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML",disable_web_page_preview:true}')" \
|
||||
>/dev/null
|
||||
@@ -10,10 +10,12 @@ show the full path.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import or_, select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from src.db.base import get_db_context
|
||||
from src.db.models import (
|
||||
@@ -76,6 +78,40 @@ _EVENT_STAGE_MAP = {
|
||||
"close": "close",
|
||||
"resolved": "close",
|
||||
}
|
||||
_AUTOMATION_STAGE_MAP = {
|
||||
"monitor_configured": "investigator",
|
||||
"monitor_removed": "safe",
|
||||
"alert_fired": "webhook",
|
||||
"alert_suppressed": "safe",
|
||||
"alert_routed": "safe",
|
||||
"rule_created": "km",
|
||||
"rule_updated": "km",
|
||||
"rule_matched": "ai_router",
|
||||
"rule_rejected": "safe",
|
||||
"rule_deprecated": "km",
|
||||
"playbook_generated": "km",
|
||||
"playbook_updated": "km",
|
||||
"playbook_executed": "executor",
|
||||
"remediation_executed": "executor",
|
||||
"remediation_verified": "verifier",
|
||||
"remediation_rolled_back": "executor",
|
||||
"self_correction_attempted": "verifier",
|
||||
"km_created": "km",
|
||||
"km_updated": "km",
|
||||
"km_linked": "km",
|
||||
"asset_discovered": "investigator",
|
||||
"coverage_recalculated": "verifier",
|
||||
"capacity_recommendation": "investigator",
|
||||
"quota_enforced": "safe",
|
||||
"notification_formatted": "safe",
|
||||
}
|
||||
_AUTOMATION_STATUS_MAP = {
|
||||
"pending": "pending",
|
||||
"success": "success",
|
||||
"failed": "error",
|
||||
"dry_run": "info",
|
||||
"rolled_back": "warning",
|
||||
}
|
||||
|
||||
|
||||
def _value(value: Any) -> Any:
|
||||
@@ -159,6 +195,81 @@ def _stage_from_event_type(event_type: str | None) -> str:
|
||||
return _EVENT_STAGE_MAP.get((event_type or "").lower(), "safe")
|
||||
|
||||
|
||||
def _stage_from_automation_op(operation_type: Any) -> str:
|
||||
return _AUTOMATION_STAGE_MAP.get(str(operation_type or "").lower(), "safe")
|
||||
|
||||
|
||||
def _automation_status(status: Any) -> str:
|
||||
return _AUTOMATION_STATUS_MAP.get(str(status or "").lower(), "info")
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _automation_summary(row: Any) -> str | None:
|
||||
output = _as_dict(row.output)
|
||||
input_data = _as_dict(row.input)
|
||||
for key in ("summary", "message", "action", "rule_id", "playbook_id"):
|
||||
value = output.get(key) or input_data.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return row.error
|
||||
|
||||
|
||||
async def _fetch_automation_ops(
|
||||
db: Any,
|
||||
incident_id: str,
|
||||
approval_ids: list[str],
|
||||
) -> list[Any]:
|
||||
"""Best-effort ADR-090 automation_operation_log lookup for one incident."""
|
||||
params: dict[str, Any] = {"incident_id": incident_id}
|
||||
approval_clause = ""
|
||||
if approval_ids:
|
||||
placeholders = []
|
||||
for idx, approval_id in enumerate(approval_ids):
|
||||
key = f"approval_id_{idx}"
|
||||
params[key] = approval_id
|
||||
placeholders.append(f":{key}")
|
||||
in_list = ", ".join(placeholders)
|
||||
approval_clause = (
|
||||
f" OR input ->> 'approval_id' IN ({in_list})"
|
||||
f" OR output ->> 'approval_id' IN ({in_list})"
|
||||
)
|
||||
|
||||
try:
|
||||
rows = await db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
op_id::text AS op_id,
|
||||
operation_type,
|
||||
actor,
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
duration_ms,
|
||||
tags,
|
||||
created_at
|
||||
FROM automation_operation_log
|
||||
WHERE input ->> 'incident_id' = :incident_id
|
||||
OR output ->> 'incident_id' = :incident_id
|
||||
{approval_clause}
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
return [SimpleNamespace(**dict(row)) for row in rows.mappings().all()]
|
||||
except SQLAlchemyError as exc:
|
||||
logger.debug(
|
||||
"incident_timeline_automation_log_skipped",
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def format_ascii_timeline(stages: list[dict[str, Any]]) -> str:
|
||||
"""Compact ASCII line for Telegram and logs."""
|
||||
marks = {
|
||||
@@ -246,6 +357,7 @@ async def fetch_incident_timeline(incident_id: str) -> dict[str, Any] | None:
|
||||
.limit(100)
|
||||
)
|
||||
).scalars().all()
|
||||
automation_ops = await _fetch_automation_ops(db, incident_id, approval_ids)
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
@@ -486,6 +598,24 @@ async def fetch_incident_timeline(incident_id: str) -> dict[str, Any] | None:
|
||||
},
|
||||
))
|
||||
|
||||
for op in automation_ops:
|
||||
events.append(_event(
|
||||
stage=_stage_from_automation_op(op.operation_type),
|
||||
status=_automation_status(op.status),
|
||||
title=f"Automation: {op.operation_type}",
|
||||
timestamp=op.created_at,
|
||||
description=_automation_summary(op),
|
||||
actor=op.actor,
|
||||
source_table="automation_operation_log",
|
||||
data={
|
||||
"op_id": op.op_id,
|
||||
"operation_type": op.operation_type,
|
||||
"status": op.status,
|
||||
"duration_ms": op.duration_ms,
|
||||
"tags": op.tags or [],
|
||||
},
|
||||
))
|
||||
|
||||
events.sort(key=lambda e: e["timestamp"] or "")
|
||||
for event in events:
|
||||
_apply_event(stages, event)
|
||||
|
||||
@@ -217,6 +217,58 @@ class TelegramMessage:
|
||||
nemotron_validation: str = "" # "✅ 驗證通過" / "❌ 驗證失敗" / "⏳ 驗證中"
|
||||
nemotron_latency_ms: float = 0.0 # Nemotron 呼叫延遲 (ms)
|
||||
|
||||
def _provider_display(self) -> tuple[str, str]:
|
||||
"""Return display provider and optional model suffix."""
|
||||
provider_names = {
|
||||
"ollama": "Ollama",
|
||||
"gemini": "Gemini",
|
||||
"claude": "Claude",
|
||||
"nvidia": "Nemotron",
|
||||
"openclaw_nemo": "OpenClaw Nemo",
|
||||
"openclaw_nvidia_nim": "OpenClaw Nemo",
|
||||
"openclaw_qwen": "OpenClaw Nemo",
|
||||
}
|
||||
provider = (self.ai_provider or "").strip().lower()
|
||||
if provider:
|
||||
provider_display = provider_names.get(provider, self.ai_provider.upper())
|
||||
elif self.confidence > 0:
|
||||
provider_display = "AI Router"
|
||||
else:
|
||||
provider_display = "rule_fallback"
|
||||
model_suffix = f" ({html.escape(self.ai_model)})" if self.ai_model else ""
|
||||
return provider_display, model_suffix
|
||||
|
||||
def _automation_mode(self) -> str:
|
||||
text = f"{self.root_cause} {self.suggested_action}".lower()
|
||||
if "超時" in text or "timeout" in text:
|
||||
return "llm_timeout_manual_gate"
|
||||
if self.confidence > 0 and self.suggested_action and self.suggested_action != "待分析":
|
||||
return "ai_proposal_ready"
|
||||
if self.suggested_action in {"待分析", "", "NO_ACTION"}:
|
||||
return "analysis_degraded"
|
||||
return "safe_gate_pending"
|
||||
|
||||
def _format_automation_block(self) -> str:
|
||||
"""Visible AI automation chain for every ACTION REQUIRED card."""
|
||||
provider_display, model_suffix = self._provider_display()
|
||||
mode = self._automation_mode()
|
||||
openclaw_state = provider_display if provider_display != "rule_fallback" else "degraded"
|
||||
nemotron_state = "tool_ready" if self.nemotron_enabled else "standby"
|
||||
hermes_state = self.playbook_name or "rule_catalog"
|
||||
elephant_state = "timeline_km_pending"
|
||||
flow = "webhook>investigator>router>llm/rule>safe>approval"
|
||||
|
||||
return (
|
||||
f"🤖 <b>AI 自動化鏈路</b>\n"
|
||||
f"├ Router:<code>{html.escape(provider_display)}{model_suffix}</code>\n"
|
||||
f"├ Mode:<code>{html.escape(mode)}</code>\n"
|
||||
f"├ OpenClaw:<code>{html.escape(openclaw_state)}</code> | "
|
||||
f"NemoTron:<code>{html.escape(nemotron_state)}</code>\n"
|
||||
f"├ Hermes:<code>{html.escape(hermes_state)}</code> | "
|
||||
f"ElephantAlpha:<code>{html.escape(elephant_state)}</code>\n"
|
||||
f"└ Flow:<code>{flow}</code>\n"
|
||||
)
|
||||
|
||||
def format(self) -> str:
|
||||
"""
|
||||
格式化為 SOUL.md 規範的訊息 (含 AI 仲裁 + SignOz)
|
||||
@@ -320,22 +372,12 @@ class TelegramMessage:
|
||||
# ADR-075 TYPE-3 格式 (2026-04-12 ogt)
|
||||
# AI 來源標籤:confidence=0 不顯示 0%,顯示 📋 規則分析
|
||||
if self.confidence > 0 and self.ai_provider:
|
||||
provider_names = {
|
||||
"ollama": "Ollama",
|
||||
"gemini": "Gemini",
|
||||
"claude": "Claude",
|
||||
"nvidia": "Nemotron",
|
||||
"openclaw_nemo": "Nemotron",
|
||||
"openclaw_nvidia_nim": "Nemotron",
|
||||
"openclaw_qwen": "Nemotron",
|
||||
}
|
||||
provider_display = provider_names.get(self.ai_provider.lower(), self.ai_provider.upper())
|
||||
model_suffix = f" ({html.escape(self.ai_model)})" if self.ai_model else ""
|
||||
provider_display, model_suffix = self._provider_display()
|
||||
ai_source = f"🤖 <b>{provider_display}{model_suffix}</b> {conf_emoji} {confidence_pct}%"
|
||||
elif self.confidence > 0:
|
||||
ai_source = f"🤖 <b>AI 仲裁</b> {conf_emoji} {confidence_pct}%"
|
||||
else:
|
||||
ai_source = "📋 規則分析"
|
||||
ai_source = "⚙️ <b>規則/降級分析</b>"
|
||||
|
||||
# 風險等級中文
|
||||
risk_zh = {
|
||||
@@ -368,16 +410,18 @@ class TelegramMessage:
|
||||
playbook_line = ""
|
||||
if self.playbook_name:
|
||||
playbook_line = f"📖 Playbook:<code>{html.escape(self.playbook_name)}</code>\n"
|
||||
automation_block = self._format_automation_block()
|
||||
|
||||
# ADR-075 TYPE-3 格式組裝
|
||||
message = (
|
||||
f"{self.status_emoji} ACTION REQUIRED | <b>{html.escape(risk_zh)}</b>\n"
|
||||
f"──────────────────────\n"
|
||||
f"📋 <code>{html.escape(incident_id)}</code>\n"
|
||||
f"流程:<code>webhook>investigator>ai>safe>executor>verifier>km</code>\n"
|
||||
f"🎯 資源:<code>{safe_resource}</code>\n"
|
||||
f"{category_line}"
|
||||
f"\n"
|
||||
f"{automation_block}"
|
||||
f"\n"
|
||||
f"🧠 <b>AI 深度診斷</b>\n"
|
||||
f"├─ 分析:{safe_root_cause}\n"
|
||||
f"├─ 責任:{resp_display}\n"
|
||||
@@ -461,17 +505,7 @@ class TelegramMessage:
|
||||
# 2026-04-04 ogt: 加入 ai_model 顯示底層模型名稱
|
||||
# 2026-04-12 ogt: 規則匹配不顯示 🔴 0%,改用 ✅
|
||||
if self.confidence > 0 and self.ai_provider:
|
||||
provider_names = {
|
||||
"ollama": "Ollama",
|
||||
"gemini": "Gemini",
|
||||
"claude": "Claude",
|
||||
"nvidia": "Nemotron",
|
||||
"openclaw_nemo": "OpenClaw Nemo",
|
||||
"openclaw_nvidia_nim": "OpenClaw Nemo",
|
||||
"openclaw_qwen": "OpenClaw Nemo",
|
||||
}
|
||||
provider_display = provider_names.get(self.ai_provider.lower(), self.ai_provider.upper())
|
||||
model_suffix = f" ({html.escape(self.ai_model)})" if self.ai_model else ""
|
||||
provider_display, model_suffix = self._provider_display()
|
||||
conf_line = f"🤖 <b>{provider_display} 仲裁</b>{model_suffix} {conf_emoji} {confidence_pct}%"
|
||||
elif self.confidence > 0:
|
||||
conf_line = f"🤖 <b>OpenClaw 仲裁</b> {conf_emoji} {confidence_pct}%"
|
||||
@@ -538,6 +572,7 @@ class TelegramMessage:
|
||||
f"<b>{safe_resource}</b>\n"
|
||||
f"{category_line}"
|
||||
f"\n"
|
||||
f"{self._format_automation_block()}\n"
|
||||
f"{conf_line}\n"
|
||||
f"👥 {resp_display}\n"
|
||||
f"💡 {safe_root_cause}\n"
|
||||
|
||||
52
apps/api/tests/test_telegram_ai_automation_block.py
Normal file
52
apps/api/tests/test_telegram_ai_automation_block.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from src.services.telegram_gateway import TelegramMessage
|
||||
|
||||
|
||||
def test_action_required_card_exposes_ai_automation_on_fallback() -> None:
|
||||
message = TelegramMessage(
|
||||
status_emoji="🚨",
|
||||
risk_level="CRITICAL",
|
||||
resource_name="node-exporter-110",
|
||||
root_cause="AI 分析超時(90s),降級至人工審核",
|
||||
suggested_action="待分析",
|
||||
estimated_downtime="5-15 min",
|
||||
approval_id="test-approval-id",
|
||||
incident_id="INC-20260429-TEST01",
|
||||
primary_responsibility="INFRA",
|
||||
confidence=0.0,
|
||||
)
|
||||
|
||||
body = message.format()
|
||||
|
||||
assert "AI 自動化鏈路" in body
|
||||
assert "rule_fallback" in body
|
||||
assert "llm_timeout_manual_gate" in body
|
||||
assert "OpenClaw" in body
|
||||
assert "NemoTron" in body
|
||||
assert "Hermes" in body
|
||||
assert "ElephantAlpha" in body
|
||||
|
||||
|
||||
def test_nemotron_card_exposes_same_ai_automation_chain() -> None:
|
||||
message = TelegramMessage(
|
||||
status_emoji="🚨",
|
||||
risk_level="CRITICAL",
|
||||
resource_name="awoooi-api",
|
||||
root_cause="Pod restart loop",
|
||||
suggested_action="restart deployment/awoooi-api",
|
||||
estimated_downtime="30s",
|
||||
approval_id="test-approval-id",
|
||||
incident_id="INC-20260429-TEST02",
|
||||
primary_responsibility="INFRA",
|
||||
confidence=0.86,
|
||||
ai_provider="openclaw_nemo",
|
||||
ai_model="llama-3.1-nemotron",
|
||||
nemotron_enabled=True,
|
||||
playbook_name="restart_deployment",
|
||||
)
|
||||
|
||||
body = message.format_with_nemotron()
|
||||
|
||||
assert "AI 自動化鏈路" in body
|
||||
assert "OpenClaw Nemo" in body
|
||||
assert "tool_ready" in body
|
||||
assert "restart_deployment" in body
|
||||
@@ -38,7 +38,11 @@ class TestTelegramMessageFormat:
|
||||
assert "🚨" in result
|
||||
assert "嚴重" in result
|
||||
assert "test-pod-123" in result
|
||||
assert len(result) <= 900 # SOUL.md 限制
|
||||
assert "AI 自動化鏈路" in result
|
||||
assert "OpenClaw" in result
|
||||
assert "NemoTron" in result
|
||||
assert "ElephantAlpha" in result
|
||||
assert len(result) <= 4096 # Telegram HTML message limit
|
||||
|
||||
def test_telegram_message_with_token_cost(self):
|
||||
"""測試含 Token/Cost 的訊息"""
|
||||
|
||||
139
apps/web/src/app/[locale]/code-review/page.tsx
Normal file
139
apps/web/src/app/[locale]/code-review/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Gauge,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
const GITEA_ACTIONS_URL = 'http://192.168.0.110:3001/wooo/awoooi/actions'
|
||||
|
||||
const agents = [
|
||||
{ name: 'Hermes', role: '變更摘要與規則脈絡', state: 'wired' },
|
||||
{ name: 'OpenClaw', role: 'AI review orchestration', state: 'wired' },
|
||||
{ name: 'Elephant Alpha', role: '風險分級與修復決策', state: 'wired' },
|
||||
{ name: 'NemoTron', role: '高風險推理席位', state: 'standby' },
|
||||
]
|
||||
|
||||
const stages = [
|
||||
{ label: 'push', state: 'Gitea main' },
|
||||
{ label: 'start', state: 'Telegram card' },
|
||||
{ label: 'scan', state: 'secret / destructive / diff-check' },
|
||||
{ label: 'grade', state: 'CRITICAL / HIGH / MEDIUM / LOW' },
|
||||
{ label: 'finish', state: 'Telegram report' },
|
||||
]
|
||||
|
||||
export default function CodeReviewPage({ params }: { params: { locale: string } }) {
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<main className="min-h-screen bg-[#0f1115] text-gray-100">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<header className="flex flex-col gap-4 border-b border-gray-800 pb-5 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono uppercase text-emerald-300">
|
||||
<SearchCheck className="h-4 w-4" />
|
||||
AWOOOI Code Review
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-white">AI Code Review 控制面</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Hermes → OpenClaw → Elephant Alpha → NemoTron
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={GITEA_ACTIONS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded border border-emerald-500/40 px-3 text-sm font-medium text-emerald-200 transition-colors hover:border-emerald-300 hover:bg-emerald-500/10"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
查看 Gitea Actions
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded border border-gray-800 bg-gray-950 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<GitBranch className="h-4 w-4 text-emerald-300" />
|
||||
Source
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold text-white">gitea main</div>
|
||||
<div className="mt-1 text-xs text-gray-500">192.168.0.110:3001</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-800 bg-gray-950 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Activity className="h-4 w-4 text-sky-300" />
|
||||
Trigger
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold text-white">push / manual</div>
|
||||
<div className="mt-1 text-xs text-gray-500">Code Review workflow</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-800 bg-gray-950 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-300" />
|
||||
Gate
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold text-white">non-blocking v1</div>
|
||||
<div className="mt-1 text-xs text-gray-500">critical / high visible</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-800 bg-gray-950 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Gauge className="h-4 w-4 text-lime-300" />
|
||||
Report
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-semibold text-white">Telegram + Actions</div>
|
||||
<div className="mt-1 text-xs text-gray-500">start and completion cards</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||
Review Pipeline
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{stages.map((stage, index) => (
|
||||
<div
|
||||
key={stage.label}
|
||||
className="grid grid-cols-[2rem_8rem_1fr] items-center rounded border border-gray-800 bg-gray-950 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-gray-500">{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className="font-mono text-emerald-200">{stage.label}</span>
|
||||
<span className="truncate text-gray-300">{stage.state}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Bot className="h-4 w-4 text-sky-300" />
|
||||
Agent Assignment
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.name}
|
||||
className="grid grid-cols-[8rem_1fr_4.5rem] items-center rounded border border-gray-800 bg-gray-950 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-semibold text-white">{agent.name}</span>
|
||||
<span className="truncate text-gray-400">{agent.role}</span>
|
||||
<span className="justify-self-end rounded bg-emerald-500/10 px-2 py-1 text-xs font-mono text-emerald-200">
|
||||
{agent.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,23 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 | Telegram AI 鏈路 + Code Review 可見化
|
||||
|
||||
統帥截圖指出 Telegram ACTION REQUIRED 卡片仍看不到 AI 自動化;另要求把 Code Review 啟動/完成通知機制納入 AWOOOI 推進項目。
|
||||
|
||||
### 完成
|
||||
- Telegram ACTION REQUIRED / Nemotron 卡片固定顯示 `AI 自動化鏈路`,露出 Router、Mode、OpenClaw、NemoTron、Hermes、ElephantAlpha 與 webhook→approval flow。
|
||||
- Incident timeline 聚合補進 ADR-090 `automation_operation_log`,讓 AI 自動化動作可回掛 incident detail。
|
||||
- 新增 Gitea Actions `Code Review` workflow:push main 後送「啟動」與「完成」Telegram 卡,完成卡列 CRITICAL/HIGH/MEDIUM/LOW、風險等級、Elephant Alpha 修復建議與 `https://mo.wooo.work/code-review/`。
|
||||
- 新增 deterministic CI reviewer,先做 secret / destructive command / `git diff --check` 掃描,輸出 sanitized JSON,不把疑似 secret 原文打到 log。
|
||||
- 新增 `/code-review/` 前端控制面,連到正確 Gitea Actions:`http://192.168.0.110:3001/wooo/awoooi/actions`。
|
||||
|
||||
### 驗證
|
||||
- `py_compile` Telegram/timeline/reviewer 通過。
|
||||
- `pytest tests/test_telegram_ai_automation_block.py tests/test_telegram_message_templates.py tests/test_incident_timeline_service.py -q` 通過。
|
||||
- `pnpm --filter @awoooi/web typecheck` 通過。
|
||||
- `.gitea/workflows/code-review.yaml` YAML parse + run shell `bash -n` 通過。
|
||||
|
||||
## 2026-04-29 | Wave B 事件處理歷程透明化
|
||||
|
||||
Codex 接續 AI 自動化 Wave B,先把「告警→AI→安全閘→執行→驗證→KM」處理鏈變成可查、可顯示、可發 Telegram 的事件 timeline。
|
||||
|
||||
168
scripts/ci_code_review.py
Executable file
168
scripts/ci_code_review.py
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic AWOOOI CI code review summary.
|
||||
|
||||
The workflow-level reviewer intentionally avoids printing matching source lines
|
||||
so suspected secrets never leak into CI logs or Telegram. It produces a compact
|
||||
JSON report for the notification layer, while the heavier LLM reviewer can be
|
||||
plugged in behind the same report shape later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SECRET_PATTERN = re.compile(
|
||||
r"(AIza[0-9A-Za-z_-]{20,}|sk-[A-Za-z0-9]{20,}|"
|
||||
r"(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{16,})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HIGH_RISK_PATTERN = re.compile(
|
||||
r"(kubectl\s+delete|DROP\s+TABLE|TRUNCATE\s+TABLE|git\s+reset\s+--hard|rm\s+-rf\s+/)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _run(args: list[str], cwd: Path, check: bool = False) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _git_lines(args: list[str], cwd: Path) -> list[str]:
|
||||
result = _run(["git", *args], cwd)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _resolve_range(base: str | None, head: str, cwd: Path) -> str:
|
||||
if base and base.strip("0"):
|
||||
base_ok = _run(["git", "rev-parse", "--verify", f"{base}^{{commit}}"], cwd)
|
||||
if base_ok.returncode == 0:
|
||||
return f"{base}..{head}"
|
||||
|
||||
parent_ok = _run(["git", "rev-parse", "--verify", f"{head}^"], cwd)
|
||||
if parent_ok.returncode == 0:
|
||||
return f"{head}^..{head}"
|
||||
return head
|
||||
|
||||
|
||||
def _changed_files(git_range: str, cwd: Path) -> list[str]:
|
||||
files = _git_lines(["diff", "--name-only", git_range], cwd)
|
||||
if files:
|
||||
return files
|
||||
return _git_lines(["show", "--pretty=", "--name-only", git_range.split("..")[-1]], cwd)
|
||||
|
||||
|
||||
def _added_lines_for_file(git_range: str, file_path: str, cwd: Path) -> list[str]:
|
||||
result = _run(["git", "diff", "--unified=0", "--no-color", git_range, "--", file_path], cwd)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
line[1:]
|
||||
for line in result.stdout.splitlines()
|
||||
if line.startswith("+") and not line.startswith("+++")
|
||||
]
|
||||
|
||||
|
||||
def _diff_check_count(git_range: str, cwd: Path) -> int:
|
||||
result = _run(["git", "diff", "--check", git_range], cwd)
|
||||
if result.returncode == 0:
|
||||
return 0
|
||||
return len([line for line in result.stdout.splitlines() if line.strip()])
|
||||
|
||||
|
||||
def build_report(base: str | None, head: str, cwd: Path) -> dict[str, Any]:
|
||||
git_range = _resolve_range(base, head, cwd)
|
||||
files = _changed_files(git_range, cwd)
|
||||
|
||||
secret_files: list[str] = []
|
||||
high_risk_files: list[str] = []
|
||||
for file_path in files:
|
||||
added_lines = _added_lines_for_file(git_range, file_path, cwd)
|
||||
if any(SECRET_PATTERN.search(line) for line in added_lines):
|
||||
secret_files.append(file_path)
|
||||
if any(HIGH_RISK_PATTERN.search(line) for line in added_lines):
|
||||
high_risk_files.append(file_path)
|
||||
|
||||
medium = _diff_check_count(git_range, cwd)
|
||||
counts = {
|
||||
"critical": len(secret_files),
|
||||
"high": len(high_risk_files),
|
||||
"medium": medium,
|
||||
"low": 0,
|
||||
}
|
||||
|
||||
if counts["critical"]:
|
||||
risk = "CRITICAL"
|
||||
summary = "疑似密鑰或高敏感憑證進入 diff,需立即人工確認。"
|
||||
action = "阻擋部署並清除憑證"
|
||||
top_issue = "敏感輸入異常:變更檔案中出現疑似 secret"
|
||||
elif counts["high"]:
|
||||
risk = "HIGH"
|
||||
summary = "偵測到破壞性操作語句,需確認是否符合變更窗口與回滾計畫。"
|
||||
action = "人工複核高風險操作"
|
||||
top_issue = "破壞性操作:kubectl delete / DROP / rm -rf 等模式"
|
||||
elif counts["medium"]:
|
||||
risk = "MEDIUM"
|
||||
summary = "格式或 whitespace 檢查有異常,建議在合併前修正。"
|
||||
action = "修正 diff check 註記"
|
||||
top_issue = "格式檢查異常:git diff --check 回報問題"
|
||||
else:
|
||||
risk = "LOW"
|
||||
summary = "未發現高風險問題,靜態掃描通過。"
|
||||
action = "無需修復動作"
|
||||
top_issue = "無"
|
||||
|
||||
return {
|
||||
"range": git_range,
|
||||
"head": head,
|
||||
"files": files,
|
||||
"counts": counts,
|
||||
"risk": risk,
|
||||
"summary": summary,
|
||||
"action": action,
|
||||
"top_issue": top_issue,
|
||||
"agents": ["Hermes", "OpenClaw", "ElephantAlpha", "NemoTron"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", default="")
|
||||
parser.add_argument("--head", required=True)
|
||||
parser.add_argument("--repo", default=".")
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
report = build_report(args.base or None, args.head, Path(args.repo).resolve())
|
||||
Path(args.output).write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
"Code review report:",
|
||||
json.dumps(
|
||||
{
|
||||
"risk": report["risk"],
|
||||
"counts": report["counts"],
|
||||
"files": len(report["files"]),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user