feat(flywheel): surface ai automation and code review
Some checks failed
Code Review / ai-code-review (push) Successful in 31s
CD Pipeline / build-and-deploy (push) Failing after 5m23s

This commit is contained in:
Your Name
2026-04-30 00:09:25 +08:00
parent d197e2785d
commit 639bb64788
8 changed files with 734 additions and 26 deletions

View 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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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

View File

@@ -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)

View File

@@ -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&gt;investigator&gt;router&gt;llm/rule&gt;safe&gt;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&gt;investigator&gt;ai&gt;safe&gt;executor&gt;verifier&gt;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"

View 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

View File

@@ -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 的訊息"""

View 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>
)
}

View File

@@ -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` workflowpush 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
View 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())