feat(api): Add sync-from-approvals endpoint for incident backfill
Fixes existing approvals created before b645981 that lack
corresponding incidents. Ensures "活躍事件" count matches
"待簽核" count.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"412c1507-44d4-4702-bb80-f37e97b804a7","pid":5408,"acquiredAt":1774326092203}
|
||||
@@ -370,7 +370,44 @@
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'echo \"\"=== 日誌行數 ===\"\" && wc -l /tmp/sentry-install.log && echo \"\"\"\" && echo \"\"=== 最近 20 行 ===\"\" && tail -20 /tmp/sentry-install.log')",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'echo \"\"=== 日誌行數 ===\"\" && wc -l /tmp/sentry-install.log && echo \"\"\"\" && echo \"\"=== 關鍵階段 ===\"\" && grep -E \"\"^▶|✓|Error|Creating|Starting|Building|DONE\"\" /tmp/sentry-install.log | tail -30')",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'echo \"\"=== 日誌行數 ===\"\" && wc -l /tmp/sentry-install.log && echo \"\"\"\" && echo \"\"=== 最近關鍵階段 ===\"\" && grep -E \"\"^▶|✓|Error|Creating|Starting|DONE|Completed|success\"\" /tmp/sentry-install.log | tail -25')",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'grep -E \"\"^▶|✓|Error|Completed|success|fail\"\" /tmp/sentry-install.log | tail -15')"
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'grep -E \"\"^▶|✓|Error|Completed|success|fail\"\" /tmp/sentry-install.log | tail -15')",
|
||||
"Bash(redis-cli -h 192.168.0.188 -p 6380 KEYS incident:*)",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"cat /home/ollama/momo-pro/monitoring/alertmanager.yml 2>/dev/null || cat /etc/alertmanager/alertmanager.yml 2>/dev/null || echo ''Config not found''\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"docker logs clawbot --tail 30 2>&1\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"docker logs clawbot --tail 20 2>&1 | grep -iE ''telegram|send|alert|incident|error''\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"cat /home/ollama/clawbot-v5/.env | grep -E ''TELEGRAM|TG_'' | head -5\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"cat /home/ollama/clawbot-v5/.env | grep -E ''REDIS|POSTGRES|DATABASE'' | head -5\")",
|
||||
"Bash(ssh ollama@192.168.0.188 'curl -s \"\"http://localhost:9093/api/v2/alerts?active=true\"\" | python3 -c \"\"import sys,json; alerts=json.load\\(sys.stdin\\); print\\(f\\\\\"\"Active alerts: {len\\(alerts\\)}\\\\\"\"\\)\"\"')",
|
||||
"Bash(ssh ollama@192.168.0.188 'curl -s \"\"http://localhost:9093/api/v2/alerts\"\" | python3 -c \"\"import sys,json; alerts=json.load\\(sys.stdin\\); print\\(f\\\\\"\"Total alerts: {len\\(alerts\\)}\\\\\"\"\\); [print\\(a[\\\\\"\"labels\\\\\"\"][\\\\\"\"alertname\\\\\"\"]\\) for a in alerts[:5]]\"\"')",
|
||||
"Bash(ssh ollama@192.168.0.188 'redis-cli -p 6380 -n 0 GET incident:INC-20260324-36AF55 | python3 -c \"\"import sys,json; d=json.load\\(sys.stdin\\); print\\(f\\\\\"\"Status: {d.get\\(\\\\\"\"status\\\\\"\"\\)}\\\\\"\"\\); print\\(f\\\\\"\"message_id: {d.get\\(\\\\\"\"message_id\\\\\"\", \\\\\"\"NONE\\\\\"\"\\)}\\\\\"\"\\); print\\(f\\\\\"\"chat_id: {d.get\\(\\\\\"\"chat_id\\\\\"\", \\\\\"\"NONE\\\\\"\"\\)}\\\\\"\"\\)\"\"')",
|
||||
"Bash(ssh ollama@192.168.0.188 'redis-cli -p 6380 -n 0 GET incident:INC-20260324-36AF55 | python3 -c \"\"import sys,json; d=json.load\\(sys.stdin\\); print\\(f\\\\\"\"status: {d.get\\('status'\\)}\\\\\"\"\\); print\\(f\\\\\"\"message_id: {d.get\\('message_id'\\)}\\\\\"\"\\); print\\(f\\\\\"\"created_at: {d.get\\('created_at'\\)}\\\\\"\"\\)\"\"')",
|
||||
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *approval*)",
|
||||
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *incident*)",
|
||||
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *pending*)",
|
||||
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *)",
|
||||
"Bash(KUBECONFIG=/Users/ogt/awoooi/k3s-prod.yaml kubectl get pods -n awoooi-prod -o wide)",
|
||||
"Bash(KUBECONFIG=/Users/ogt/awoooi/k3s-prod.yaml kubectl get deployment awoooi-api -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[0].image}')",
|
||||
"Bash(kubectl --kubeconfig=/Users/ogt/awoooi/k3s-prod.yaml get deployment awoooi-api -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[0].image}')",
|
||||
"Bash(python3 -c \":*)",
|
||||
"Bash(/tmp/awoooi-tg-secret.yaml:*)",
|
||||
"Bash(KUBECONFIG=/Users/ogt/awoooi/k3s-prod.yaml kubectl apply -f /tmp/awoooi-tg-secret.yaml)",
|
||||
"Bash(for pod:*)",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.188 \"curl -fsSL https://ollama.com/install.sh | sh\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no -o PreferredAuthentications=password wooo@192.168.0.188 \"echo connected && ollama --version\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no -o PreferredAuthentications=password ollama@192.168.0.188 \"curl -fsSL https://ollama.com/install.sh | sh\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S curl -fsSL https://ollama.com/install.sh | sudo -S sh\")",
|
||||
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"ollama --version\")",
|
||||
"Bash(__NEW_LINE_95e9df111552805b__ echo:*)",
|
||||
"Bash(sshpass -p '0936223270' scp /Users/ogt/awoooi/k8s/nginx/awoooi-prod.conf ollama@192.168.0.188:/tmp/awoooi-prod.conf)",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S cp /tmp/awoooi-prod.conf /etc/nginx/conf.d/awoooi-prod.conf && echo ''0936223270'' | sudo -S nginx -t 2>&1\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S ls -la /etc/nginx/ssl/ 2>/dev/null || echo ''No ssl dir''; echo ''0936223270'' | sudo -S ls -la /etc/nginx/conf.d/ 2>/dev/null | head -10\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S grep -r ''ssl_certificate'' /etc/nginx/ 2>/dev/null | head -5\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S grep -A 20 ''server_name awoooi'' /etc/nginx/sites-enabled/all-sites.conf 2>/dev/null | head -30\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S ls -la /etc/nginx/sites-enabled/ 2>/dev/null\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S cat /etc/nginx/sites-available/awoooi.wooo.work.conf 2>/dev/null\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S rm /etc/nginx/conf.d/awoooi-prod.conf && echo ''0936223270'' | sudo -S nginx -t 2>&1\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S nginx -s reload 2>&1\")",
|
||||
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S systemctl reload nginx 2>&1\")"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf *)",
|
||||
|
||||
1
.claude/worktrees/reverent-gauss
Submodule
BIN
Gemini_Generated_Image_sxbfrvsxbfrvsxbf (2).png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
Gemini_Generated_Image_sxbfrvsxbfrvsxbf.png
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
@@ -622,3 +622,153 @@ async def debug_resolve_incident(incident_id: str) -> dict[str, Any]:
|
||||
"updates": {"redis": redis_updated, "db": db_updated},
|
||||
"error": error_msg,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/v1/incidents/sync-from-approvals - 補建 Incidents
|
||||
# =============================================================================
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
"""同步結果"""
|
||||
synced: int
|
||||
skipped: int
|
||||
errors: list[str]
|
||||
details: list[dict[str, Any]]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sync-from-approvals",
|
||||
response_model=SyncResult,
|
||||
summary="從現有 Approvals 補建 Incidents",
|
||||
description="""
|
||||
修復歷史數據:為沒有對應 Incident 的 Approvals 補建。
|
||||
|
||||
用途:
|
||||
- 修復 b645981 之前創建的 Approvals(無 Incident)
|
||||
- 確保「活躍事件」與「待簽核」數量一致
|
||||
""",
|
||||
)
|
||||
async def sync_incidents_from_approvals() -> SyncResult:
|
||||
"""
|
||||
掃描所有 pending Approvals,為缺少 Incident 的補建
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from src.models.incident import Signal
|
||||
from src.services.approval_db import get_approval_service
|
||||
|
||||
redis_client = get_redis()
|
||||
approval_service = get_approval_service()
|
||||
|
||||
synced = 0
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
details: list[dict[str, Any]] = []
|
||||
|
||||
# 取得所有 pending approvals
|
||||
pending_approvals = await approval_service.get_pending_approvals()
|
||||
|
||||
for approval in pending_approvals:
|
||||
approval_id = str(approval.id)
|
||||
|
||||
# 檢查是否已有 Incident 關聯此 Approval
|
||||
has_incident = False
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await redis_client.scan(
|
||||
cursor=cursor,
|
||||
match="incident:INC-*",
|
||||
count=100,
|
||||
)
|
||||
for key in keys:
|
||||
try:
|
||||
data = await redis_client.get(key)
|
||||
if data:
|
||||
incident = Incident.model_validate_json(data)
|
||||
if UUID(approval_id) in incident.proposal_ids:
|
||||
has_incident = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if has_incident or cursor == 0:
|
||||
break
|
||||
|
||||
if has_incident:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 補建 Incident
|
||||
try:
|
||||
# 映射 risk_level -> severity
|
||||
risk_map = {
|
||||
"critical": Severity.P0,
|
||||
"high": Severity.P1,
|
||||
"medium": Severity.P2,
|
||||
"low": Severity.P3,
|
||||
}
|
||||
severity = risk_map.get(approval.risk_level.value.lower(), Severity.P2)
|
||||
|
||||
# 解析 action 取得資源名稱
|
||||
action_parts = approval.action.split("|")
|
||||
resource_name = action_parts[0].strip() if action_parts else "unknown"
|
||||
|
||||
signal = Signal(
|
||||
alert_name="sync_from_approval",
|
||||
severity=severity,
|
||||
source="backfill",
|
||||
fired_at=approval.created_at,
|
||||
labels={"approval_id": approval_id},
|
||||
annotations={"description": approval.description or ""},
|
||||
)
|
||||
|
||||
incident = Incident(
|
||||
status=IncidentStatus.INVESTIGATING,
|
||||
severity=severity,
|
||||
signals=[signal],
|
||||
affected_services=[resource_name],
|
||||
proposal_ids=[UUID(approval_id)],
|
||||
created_at=approval.created_at,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
key = f"incident:{incident.incident_id}"
|
||||
await redis_client.set(
|
||||
key,
|
||||
incident.model_dump_json(),
|
||||
ex=604800, # 7 days
|
||||
)
|
||||
|
||||
synced += 1
|
||||
details.append({
|
||||
"approval_id": approval_id,
|
||||
"incident_id": incident.incident_id,
|
||||
"severity": severity.value,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"incident_synced_from_approval",
|
||||
approval_id=approval_id,
|
||||
incident_id=incident.incident_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{approval_id}: {str(e)}")
|
||||
logger.error(
|
||||
"incident_sync_error",
|
||||
approval_id=approval_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"sync_from_approvals_completed",
|
||||
synced=synced,
|
||||
skipped=skipped,
|
||||
errors=len(errors),
|
||||
)
|
||||
|
||||
return SyncResult(
|
||||
synced=synced,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
details=details,
|
||||
)
|
||||
|
||||
247
apps/api/src/api/v1/sentry_webhook.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
AWOOOI API - Sentry Webhook Handler
|
||||
====================================
|
||||
接收 Sentry Issue Alert,轉發給 OpenClaw 進行 AI 分析
|
||||
|
||||
整合流程:
|
||||
1. Sentry Alert → AWOOOI API Webhook
|
||||
2. 組裝錯誤上下文
|
||||
3. 呼叫 OpenClaw Error Analyzer Agent
|
||||
4. 結果回寫 Sentry Issue Comment
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhooks/sentry", tags=["Sentry Webhook"])
|
||||
|
||||
# OpenClaw 配置
|
||||
OPENCLAW_URL = "http://192.168.0.188:8088"
|
||||
SENTRY_API_URL = "http://192.168.0.110:9000"
|
||||
|
||||
|
||||
class SentryIssuePayload(BaseModel):
|
||||
"""Sentry Issue Alert Payload (簡化版)"""
|
||||
action: str # created, resolved, etc.
|
||||
data: dict
|
||||
actor: dict | None = None
|
||||
|
||||
|
||||
class ErrorAnalysisResult(BaseModel):
|
||||
"""錯誤分析結果"""
|
||||
root_cause: str
|
||||
impact: str
|
||||
fix_suggestion: str
|
||||
prevention: str
|
||||
confidence: float
|
||||
analyzed_by: str # ollama, claude
|
||||
|
||||
|
||||
@router.post("/error")
|
||||
async def handle_sentry_error(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Sentry Issue Webhook Handler
|
||||
|
||||
觸發條件:
|
||||
- Issue 新建 (action=created)
|
||||
- Level: error 或 fatal
|
||||
|
||||
處理流程:
|
||||
1. 解析 Sentry payload
|
||||
2. 組裝錯誤上下文
|
||||
3. 背景執行 OpenClaw 分析
|
||||
4. 回寫 Sentry Comment
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
logger.info(f"Received Sentry webhook: action={payload.get('action')}")
|
||||
|
||||
# 只處理新建的 issue
|
||||
if payload.get("action") != "triggered":
|
||||
return {"status": "ignored", "reason": "action is not triggered"}
|
||||
|
||||
# 提取錯誤資訊
|
||||
issue_data = payload.get("data", {}).get("issue", {})
|
||||
event_data = payload.get("data", {}).get("event", {})
|
||||
|
||||
error_context = {
|
||||
"issue_id": issue_data.get("id"),
|
||||
"title": issue_data.get("title"),
|
||||
"culprit": issue_data.get("culprit"),
|
||||
"level": issue_data.get("level"),
|
||||
"first_seen": issue_data.get("firstSeen"),
|
||||
"count": issue_data.get("count"),
|
||||
"project": issue_data.get("project", {}).get("slug"),
|
||||
|
||||
# 事件詳情
|
||||
"message": event_data.get("message"),
|
||||
"platform": event_data.get("platform"),
|
||||
"tags": event_data.get("tags", []),
|
||||
|
||||
# Stack trace (最後5個 frame)
|
||||
"stacktrace": _extract_stacktrace(event_data),
|
||||
}
|
||||
|
||||
# 判斷是否需要 AI 分析
|
||||
level = issue_data.get("level", "error")
|
||||
if level not in ["error", "fatal"]:
|
||||
return {"status": "ignored", "reason": f"level {level} does not require analysis"}
|
||||
|
||||
# 背景執行分析
|
||||
background_tasks.add_task(
|
||||
analyze_and_comment,
|
||||
error_context=error_context,
|
||||
issue_id=issue_data.get("id"),
|
||||
project_slug=issue_data.get("project", {}).get("slug"),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "accepted",
|
||||
"issue_id": error_context["issue_id"],
|
||||
"message": "Analysis scheduled"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Sentry webhook processing failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _extract_stacktrace(event_data: dict) -> list[dict]:
|
||||
"""提取 Stack Trace (最後5個 frame)"""
|
||||
try:
|
||||
exception = event_data.get("exception", {})
|
||||
values = exception.get("values", [])
|
||||
if not values:
|
||||
return []
|
||||
|
||||
stacktrace = values[0].get("stacktrace", {})
|
||||
frames = stacktrace.get("frames", [])
|
||||
|
||||
# 取最後5個 frame,只保留關鍵資訊
|
||||
return [
|
||||
{
|
||||
"filename": f.get("filename"),
|
||||
"function": f.get("function"),
|
||||
"lineno": f.get("lineno"),
|
||||
"context_line": f.get("context_line"),
|
||||
}
|
||||
for f in frames[-5:]
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def analyze_and_comment(
|
||||
error_context: dict,
|
||||
issue_id: str,
|
||||
project_slug: str
|
||||
):
|
||||
"""
|
||||
背景任務:分析錯誤並回寫 Sentry Comment
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting AI analysis for issue {issue_id}")
|
||||
|
||||
# 呼叫 OpenClaw 分析
|
||||
analysis = await call_openclaw_analyzer(error_context)
|
||||
|
||||
if analysis:
|
||||
# 回寫 Sentry Comment
|
||||
await post_sentry_comment(
|
||||
project_slug=project_slug,
|
||||
issue_id=issue_id,
|
||||
analysis=analysis
|
||||
)
|
||||
logger.info(f"Analysis completed for issue {issue_id}")
|
||||
else:
|
||||
logger.warning(f"Analysis returned empty for issue {issue_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Analysis failed for issue {issue_id}: {e}")
|
||||
|
||||
|
||||
async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | None:
|
||||
"""
|
||||
呼叫 OpenClaw Error Analyzer Agent
|
||||
|
||||
優先使用 Ollama (本地,零成本)
|
||||
Fallback: Claude (高嚴重性)
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{OPENCLAW_URL}/api/v1/analyze/error",
|
||||
json={
|
||||
"error_context": error_context,
|
||||
"prefer_local": True, # 優先 Ollama
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return ErrorAnalysisResult(**data)
|
||||
else:
|
||||
logger.warning(f"OpenClaw returned {response.status_code}")
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("OpenClaw analysis timeout, trying fallback prompt")
|
||||
# TODO: 直接呼叫 Ollama/Claude 作為 fallback
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"OpenClaw call failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def post_sentry_comment(
|
||||
project_slug: str,
|
||||
issue_id: str,
|
||||
analysis: ErrorAnalysisResult
|
||||
):
|
||||
"""
|
||||
回寫分析結果到 Sentry Issue Comment
|
||||
|
||||
API: POST /api/0/issues/{issue_id}/comments/
|
||||
"""
|
||||
comment_text = f"""## AI 錯誤分析 (by {analysis.analyzed_by})
|
||||
|
||||
**根本原因 (Root Cause)**
|
||||
{analysis.root_cause}
|
||||
|
||||
**影響範圍 (Impact)**
|
||||
{analysis.impact}
|
||||
|
||||
**建議修復 (Fix Suggestion)**
|
||||
{analysis.fix_suggestion}
|
||||
|
||||
**預防措施 (Prevention)**
|
||||
{analysis.prevention}
|
||||
|
||||
---
|
||||
*分析信心度: {analysis.confidence:.0%} | 分析時間: {datetime.now().isoformat()}*
|
||||
"""
|
||||
|
||||
try:
|
||||
# TODO: 需要 Sentry API Token
|
||||
# 目前先 log 出來
|
||||
logger.info(f"Would post comment to issue {issue_id}:\n{comment_text}")
|
||||
|
||||
# async with httpx.AsyncClient() as client:
|
||||
# response = await client.post(
|
||||
# f"{SENTRY_API_URL}/api/0/issues/{issue_id}/comments/",
|
||||
# headers={"Authorization": f"Bearer {SENTRY_API_TOKEN}"},
|
||||
# json={"text": comment_text}
|
||||
# )
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to post Sentry comment: {e}")
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 51 KiB |
BIN
apps/web/test-results/approval-card-check.png
Normal file
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/web/test-results/cpo102-fullpage-zh-TW.png
Normal file
|
After Width: | Height: | Size: 885 KiB |
BIN
apps/web/test-results/cpo102-hitl-section-zh-TW.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/web/test-results/cpo102-status-section-zh-TW.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/web/test-results/debug-error.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 85 KiB |
BIN
apps/web/test-results/phase3-hitl-section-final.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/web/test-results/phase3-rbac-permission-badge.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/web/test-results/phase3-user-role-display.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
apps/web/test-results/phase4-01-initial.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
apps/web/test-results/phase4-02-ai-decision.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/web/test-results/phase4-03-approval-cards.png
Normal file
|
After Width: | Height: | Size: 154 KiB |