feat(awooop): expose incident evidence links
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m1s
CD Pipeline / build-and-deploy (push) Successful in 3m26s
CD Pipeline / post-deploy-checks (push) Successful in 1m20s

This commit is contained in:
Your Name
2026-05-17 22:49:55 +08:00
parent 2d579cdf1e
commit 76c302ab5f
5 changed files with 126 additions and 11 deletions

View File

@@ -215,6 +215,13 @@ def _awooop_runs_button_row(incident_id: str) -> list[dict[str, str]]:
}]
def _awooop_runs_reply_markup(incident_id: str) -> dict | None:
row = _awooop_runs_button_row(incident_id)
if not row:
return None
return {"inline_keyboard": [row]}
def _latest_remediation_history_item(history: dict[str, object] | None) -> dict[str, object]:
if not history:
return {}
@@ -5549,6 +5556,7 @@ class TelegramGateway:
await self._send_html_line_message(
lines,
failure_context="incident_detail",
reply_markup=_awooop_runs_reply_markup(incident_id),
)
except Exception as e:
@@ -5687,6 +5695,7 @@ class TelegramGateway:
await self._send_html_line_message(
lines,
failure_context="incident_history",
reply_markup=_awooop_runs_reply_markup(incident_id),
)
except Exception as e:
@@ -5906,18 +5915,22 @@ class TelegramGateway:
*,
chat_id: str | int | None = None,
failure_context: str,
reply_markup: dict | None = None,
) -> None:
"""Send a multi-line HTML message without cutting Telegram tags in half."""
chunks = _telegram_html_chunks(lines)
for index, chunk in enumerate(chunks):
try:
payload: dict = {
"chat_id": chat_id or self.alert_chat_id,
"text": chunk,
"parse_mode": "HTML",
}
if index == 0 and reply_markup:
payload["reply_markup"] = reply_markup
await self._send_request(
"sendMessage",
{
"chat_id": chat_id or self.alert_chat_id,
"text": chunk,
"parse_mode": "HTML",
},
payload,
)
except Exception as exc:
logger.warning(
@@ -5927,12 +5940,15 @@ class TelegramGateway:
chunk_count=len(chunks),
error=str(exc),
)
fallback_payload: dict = {
"chat_id": chat_id or self.alert_chat_id,
"text": _plain_text_from_html(chunk),
}
if index == 0 and reply_markup:
fallback_payload["reply_markup"] = reply_markup
await self._send_request(
"sendMessage",
{
"chat_id": chat_id or self.alert_chat_id,
"text": _plain_text_from_html(chunk),
},
fallback_payload,
)
async def send_alert_notification(

View File

@@ -112,6 +112,7 @@ async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(mo
"""Telegram HTML parse 400 時要送純文字 fallback不可回報成歷史查詢失敗。"""
sent_requests = []
gateway = TelegramGateway()
reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21")
async def fake_send_request(method, payload):
sent_requests.append((method, payload))
@@ -125,15 +126,46 @@ async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(mo
["📊 <b>事件歷史統計</b>", "階段: <code>blocked</code>"],
chat_id="chat",
failure_context="test_history",
reply_markup=reply_markup,
)
assert len(sent_requests) == 2
assert sent_requests[0][1]["parse_mode"] == "HTML"
assert sent_requests[0][1]["reply_markup"] == reply_markup
assert "parse_mode" not in sent_requests[1][1]
assert sent_requests[1][1]["reply_markup"] == reply_markup
assert "<code>" not in sent_requests[1][1]["text"]
assert "blocked" in sent_requests[1][1]["text"]
@pytest.mark.asyncio
async def test_send_html_line_message_attaches_awooop_markup_to_first_chunk(monkeypatch):
"""詳情/歷史這類 HTML reply 要能帶 AwoooP evidence URL且長訊息只掛第一段。"""
sent_requests = []
gateway = TelegramGateway()
reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21")
async def fake_send_request(method, payload):
sent_requests.append((method, payload))
return {"ok": True}
monkeypatch.setattr(gateway, "_send_request", fake_send_request)
await gateway._send_html_line_message(
["📊 <b>事件歷史統計</b>"] + [
f"<code>INC-20260514-F85F21 trace line {index:03d}</code>"
for index in range(80)
],
chat_id="chat",
failure_context="test_history",
reply_markup=reply_markup,
)
assert len(sent_requests) > 1
assert sent_requests[0][1]["reply_markup"] == reply_markup
assert all("reply_markup" not in payload for _, payload in sent_requests[1:])
class TestTelegramMessageFormat:
"""測試現有 TelegramMessage 格式化"""

View File

@@ -1808,6 +1808,12 @@
"incidentLabel": "Incident ID filter",
"incidentPlaceholder": "Enter Incident ID"
},
"incident": {
"column": "Incident",
"empty": "Not linked",
"filterTitle": "Show only {incidentId}",
"more": "+{count} more"
},
"statuses": {
"noEvidence": "No dry-run yet",
"readOnlyDryRun": "AI dry-run: read-only",

View File

@@ -1809,6 +1809,12 @@
"incidentLabel": "Incident ID 篩選",
"incidentPlaceholder": "輸入 Incident ID"
},
"incident": {
"column": "Incident",
"empty": "尚未關聯",
"filterTitle": "只看 {incidentId}",
"more": "+{count} 筆"
},
"statuses": {
"noEvidence": "尚無試跑",
"readOnlyDryRun": "AI 已試跑:只讀",

View File

@@ -379,6 +379,55 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
);
}
function linkedIncidentIds(summary?: RemediationSummary | null): string[] {
const rawIds = summary?.incident_ids ?? [];
return Array.from(
new Set(
rawIds
.map((incidentId) => String(incidentId || "").trim().toUpperCase())
.filter((incidentId) => INCIDENT_ID_FILTER_RE.test(incidentId))
)
);
}
function IncidentIdsCell({ run }: { run: Run }) {
const t = useTranslations("awooop.listEvidence");
const incidentIds = linkedIncidentIds(run.remediation_summary);
const visibleIds = incidentIds.slice(0, 2);
const hiddenCount = Math.max(incidentIds.length - visibleIds.length, 0);
if (visibleIds.length === 0) {
return (
<span className="text-xs text-[#77736a]">
{t("incident.empty")}
</span>
);
}
return (
<div className="flex min-w-[220px] flex-wrap gap-1.5">
{visibleIds.map((incidentId) => (
<Link
key={incidentId}
href={`/awooop/runs?project_id=${encodeURIComponent(run.project_id)}&incident_id=${encodeURIComponent(incidentId)}` as never}
className="inline-flex items-center border border-[#d8d3c7] bg-[#faf9f3] px-2 py-1 font-mono text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
title={t("incident.filterTitle", { incidentId })}
>
{incidentId}
</Link>
))}
{hiddenCount > 0 && (
<span
className="inline-flex items-center border border-[#d8d3c7] bg-white px-2 py-1 font-mono text-xs text-[#5f5b52]"
title={incidentIds.slice(2).join(", ")}
>
{t("incident.more", { count: hiddenCount })}
</span>
)}
</div>
);
}
function RunRow({ run }: { run: Run }) {
const formattedDate = run.created_at
? new Date(run.created_at).toLocaleDateString("zh-TW", {
@@ -406,6 +455,9 @@ function RunRow({ run }: { run: Run }) {
{run.project_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<IncidentIdsCell run={run} />
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{run.agent_id || "--"}
@@ -843,6 +895,9 @@ export default function RunsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Project ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{tEvidence("incident.column")}
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agent
</th>
@@ -870,7 +925,7 @@ export default function RunsPage() {
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 9 }).map((_, j) => (
{Array.from({ length: 10 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>
@@ -879,7 +934,7 @@ export default function RunsPage() {
))
) : runs.length === 0 && !error ? (
<tr>
<td colSpan={9} className="px-4 py-16 text-center">
<td colSpan={10} className="px-4 py-16 text-center">
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground"> Run </p>
</td>