diff --git a/.gitea/workflows/code-review.yaml b/.gitea/workflows/code-review.yaml new file mode 100644 index 00000000..d57b2ff1 --- /dev/null +++ b/.gitea/workflows/code-review.yaml @@ -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<> "$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'; } + COMMIT_ESC="$(printf '%s' "$COMMIT_MSG" | html_escape)" + FILES_ESC="$(printf '%s\n' "$FILES_DISPLAY" | html_escape)" + MSG="$(printf '🔍 Code Review 啟動\n──────────────────────\n📦 Commit %s 🌿 %s\n📝 %s\n📁 變更檔案:\n%s\n──────────────────────\n🤖 Hermes → OpenClaw → Elephant Alpha → NemoTron\n📊 即時進度:%s' "$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'; } + 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 Code Review 完成・%s\n──────────────────────\n🔴 CRITICAL %s 🟠 HIGH %s 🟡 MEDIUM %s 🟢 LOW %s\n──────────────────────\n⚠️ 主要問題\n%s\n\n🔍 整體風險等級\n%s:%s\n\n⚠️ 最高關注問題\n1. %s\n──────────────────────\n🤖 Elephant Alpha:%s ✅ %s\n📊 完整報告:%s' "$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 diff --git a/apps/api/src/services/incident_timeline_service.py b/apps/api/src/services/incident_timeline_service.py index 0236b8e5..b14dbccc 100644 --- a/apps/api/src/services/incident_timeline_service.py +++ b/apps/api/src/services/incident_timeline_service.py @@ -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) diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 4771b3a1..97800b76 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -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"🤖 AI 自動化鏈路\n" + f"├ Router:{html.escape(provider_display)}{model_suffix}\n" + f"├ Mode:{html.escape(mode)}\n" + f"├ OpenClaw:{html.escape(openclaw_state)} | " + f"NemoTron:{html.escape(nemotron_state)}\n" + f"├ Hermes:{html.escape(hermes_state)} | " + f"ElephantAlpha:{html.escape(elephant_state)}\n" + f"└ Flow:{flow}\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"🤖 {provider_display}{model_suffix} {conf_emoji} {confidence_pct}%" elif self.confidence > 0: ai_source = f"🤖 AI 仲裁 {conf_emoji} {confidence_pct}%" else: - ai_source = "📋 規則分析" + ai_source = "⚙️ 規則/降級分析" # 風險等級中文 risk_zh = { @@ -368,16 +410,18 @@ class TelegramMessage: playbook_line = "" if self.playbook_name: playbook_line = f"📖 Playbook:{html.escape(self.playbook_name)}\n" + automation_block = self._format_automation_block() # ADR-075 TYPE-3 格式組裝 message = ( f"{self.status_emoji} ACTION REQUIRED | {html.escape(risk_zh)}\n" f"──────────────────────\n" f"📋 {html.escape(incident_id)}\n" - f"流程:webhook>investigator>ai>safe>executor>verifier>km\n" f"🎯 資源:{safe_resource}\n" f"{category_line}" f"\n" + f"{automation_block}" + f"\n" f"🧠 AI 深度診斷\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"🤖 {provider_display} 仲裁{model_suffix} {conf_emoji} {confidence_pct}%" elif self.confidence > 0: conf_line = f"🤖 OpenClaw 仲裁 {conf_emoji} {confidence_pct}%" @@ -538,6 +572,7 @@ class TelegramMessage: f"{safe_resource}\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" diff --git a/apps/api/tests/test_telegram_ai_automation_block.py b/apps/api/tests/test_telegram_ai_automation_block.py new file mode 100644 index 00000000..5ac5b24c --- /dev/null +++ b/apps/api/tests/test_telegram_ai_automation_block.py @@ -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 diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index d1f9ed7b..0c10404c 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -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 的訊息""" diff --git a/apps/web/src/app/[locale]/code-review/page.tsx b/apps/web/src/app/[locale]/code-review/page.tsx new file mode 100644 index 00000000..749ecdbe --- /dev/null +++ b/apps/web/src/app/[locale]/code-review/page.tsx @@ -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 ( + +
+
+
+
+
+ + AWOOOI Code Review +
+

AI Code Review 控制面

+

+ Hermes → OpenClaw → Elephant Alpha → NemoTron +

+
+ + + 查看 Gitea Actions + +
+ +
+
+
+ + Source +
+
gitea main
+
192.168.0.110:3001
+
+
+
+ + Trigger +
+
push / manual
+
Code Review workflow
+
+
+
+ + Gate +
+
non-blocking v1
+
critical / high visible
+
+
+
+ + Report +
+
Telegram + Actions
+
start and completion cards
+
+
+ +
+
+
+ + Review Pipeline +
+
+ {stages.map((stage, index) => ( +
+ {String(index + 1).padStart(2, '0')} + {stage.label} + {stage.state} +
+ ))} +
+
+ +
+
+ + Agent Assignment +
+
+ {agents.map((agent) => ( +
+ {agent.name} + {agent.role} + + {agent.state} + +
+ ))} +
+
+
+
+
+
+ ) +} diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index a7eec6bc..fa44a67a 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -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。 diff --git a/scripts/ci_code_review.py b/scripts/ci_code_review.py new file mode 100755 index 00000000..af8ed4c3 --- /dev/null +++ b/scripts/ci_code_review.py @@ -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())