feat(awooop): record recurrence handoff proposals
This commit is contained in:
@@ -14,12 +14,14 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.services.channel_event_dossier_service import (
|
||||
RecurrenceWorkItemHandoffKind,
|
||||
RecurrenceWorkItemMode,
|
||||
RecurrenceWorkItemNotFoundError,
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
fetch_channel_event_dossier_recurrence,
|
||||
fetch_recurrence_work_item_dry_run,
|
||||
fetch_recurrence_work_item_handoff,
|
||||
fetch_recurrence_work_item_preview,
|
||||
)
|
||||
from src.services.platform_operator_service import list_recent_channel_events
|
||||
@@ -184,6 +186,17 @@ class RecurrenceWorkItemDryRunRequest(BaseModel):
|
||||
limit: int = Field(default=300, ge=1, le=300)
|
||||
|
||||
|
||||
class RecurrenceWorkItemHandoffRequest(BaseModel):
|
||||
"""AwoooP recurrence work item handoff request."""
|
||||
|
||||
project_id: str | None = Field(default=None, min_length=1)
|
||||
work_item_id: str = Field(min_length=1)
|
||||
mode: RecurrenceWorkItemMode = "auto"
|
||||
handoff_kind: RecurrenceWorkItemHandoffKind = "ticket_proposal"
|
||||
provider: str | None = Field(default=None, min_length=1)
|
||||
limit: int = Field(default=300, ge=1, le=300)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/dossier",
|
||||
response_model=ChannelEventDossierResponse,
|
||||
@@ -313,6 +326,33 @@ async def dry_run_event_recurrence_work_item(
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/events/dossier/recurrence/work-item/handoff",
|
||||
summary="記錄重複告警工作項的交接提案",
|
||||
description=(
|
||||
"依 recurrence read model 與 dry-run 結果記錄 ticket proposal / 人工接手歷史,"
|
||||
"但不修改 incident、auto-repair 或外部 ticket 狀態。"
|
||||
),
|
||||
)
|
||||
async def handoff_event_recurrence_work_item(
|
||||
request: RecurrenceWorkItemHandoffRequest,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await fetch_recurrence_work_item_handoff(
|
||||
project_id=request.project_id,
|
||||
work_item_id=request.work_item_id,
|
||||
mode=request.mode,
|
||||
handoff_kind=request.handoff_kind,
|
||||
provider=request.provider,
|
||||
limit=request.limit,
|
||||
)
|
||||
except RecurrenceWorkItemNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="recurrence_work_item_not_found",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/recent",
|
||||
response_model=RecentEventsResponse,
|
||||
|
||||
@@ -25,6 +25,7 @@ _MAX_RECURRENCE_EVENTS = 300
|
||||
_MAX_REPAIR_INCIDENTS = 200
|
||||
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
||||
RecurrenceWorkItemMode = Literal["auto", "ticket", "reverify", "approval_review", "observe"]
|
||||
RecurrenceWorkItemHandoffKind = Literal["ticket_proposal", "manual_review"]
|
||||
|
||||
|
||||
class RecurrenceWorkItemNotFoundError(LookupError):
|
||||
@@ -671,6 +672,64 @@ def build_recurrence_work_item_dry_run(
|
||||
return payload
|
||||
|
||||
|
||||
def _recurrence_handoff_next_step(
|
||||
handoff_kind: RecurrenceWorkItemHandoffKind,
|
||||
allowed: bool,
|
||||
) -> str:
|
||||
if not allowed:
|
||||
return "fix_preflight_checks"
|
||||
if handoff_kind == "manual_review":
|
||||
return "operator_manual_review"
|
||||
return "operator_review_ticket_preview"
|
||||
|
||||
|
||||
def build_recurrence_work_item_handoff(
|
||||
recurrence: dict[str, Any],
|
||||
*,
|
||||
work_item_id: str,
|
||||
mode: RecurrenceWorkItemMode = "auto",
|
||||
handoff_kind: RecurrenceWorkItemHandoffKind = "ticket_proposal",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a record-only handoff proposal for a recurrence work item."""
|
||||
|
||||
payload = build_recurrence_work_item_dry_run(
|
||||
recurrence,
|
||||
work_item_id=work_item_id,
|
||||
mode=mode,
|
||||
)
|
||||
allowed = bool(payload.get("allowed"))
|
||||
plan = _as_dict(payload.get("plan"))
|
||||
read_model_route = _as_dict(payload.get("read_model_route"))
|
||||
payload.update(
|
||||
{
|
||||
"schema_version": "awooop_recurrence_work_item_handoff_v1",
|
||||
"handoff_kind": handoff_kind,
|
||||
"handoff_status": "ready_to_record" if allowed else "blocked",
|
||||
"handoff_owner": "operator",
|
||||
"safety_level": "handoff_record_only",
|
||||
"writes_incident_state": False,
|
||||
"writes_auto_repair_result": False,
|
||||
"writes_ticket": False,
|
||||
"creates_external_ticket": False,
|
||||
"plan": {
|
||||
**plan,
|
||||
"step": "record_recurrence_work_item_handoff",
|
||||
"flywheel_node": "handoff",
|
||||
"required_scope": "record_history",
|
||||
"writes": ["timeline_events", "alert_operation_log"],
|
||||
},
|
||||
"read_model_route": {
|
||||
**read_model_route,
|
||||
"required_scope": "record_history",
|
||||
"is_shadow": False,
|
||||
"flywheel_node": "handoff",
|
||||
},
|
||||
"next_step": _recurrence_handoff_next_step(handoff_kind, allowed),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _recurrence_history_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "awooop_recurrence_work_item_dry_run_history_v1",
|
||||
@@ -696,6 +755,35 @@ def _recurrence_history_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _recurrence_handoff_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "awooop_recurrence_work_item_handoff_history_v1",
|
||||
"source": payload.get("source"),
|
||||
"project_id": payload.get("project_id"),
|
||||
"work_item_id": payload.get("work_item_id"),
|
||||
"incident_id": payload.get("incident_id"),
|
||||
"auto_repair_id": payload.get("auto_repair_id"),
|
||||
"mode": payload.get("mode"),
|
||||
"requested_mode": payload.get("requested_mode"),
|
||||
"handoff_kind": payload.get("handoff_kind"),
|
||||
"handoff_status": payload.get("handoff_status"),
|
||||
"handoff_owner": payload.get("handoff_owner"),
|
||||
"allowed": payload.get("allowed"),
|
||||
"executed": payload.get("executed"),
|
||||
"safety_level": payload.get("safety_level"),
|
||||
"writes_incident_state": payload.get("writes_incident_state"),
|
||||
"writes_auto_repair_result": payload.get("writes_auto_repair_result"),
|
||||
"writes_ticket": payload.get("writes_ticket"),
|
||||
"creates_external_ticket": payload.get("creates_external_ticket"),
|
||||
"verification_result_preview": payload.get("verification_result_preview"),
|
||||
"current_state_summary": payload.get("current_state_summary"),
|
||||
"ticket_preview": payload.get("ticket_preview"),
|
||||
"read_model_route": payload.get("read_model_route"),
|
||||
"checks": payload.get("checks"),
|
||||
"next_step": payload.get("next_step"),
|
||||
}
|
||||
|
||||
|
||||
async def _record_recurrence_work_item_dry_run_history(
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
@@ -763,6 +851,75 @@ async def _record_recurrence_work_item_dry_run_history(
|
||||
return history
|
||||
|
||||
|
||||
async def _record_recurrence_work_item_handoff_history(
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
incident_id = str(payload.get("incident_id") or "")
|
||||
if not incident_id:
|
||||
return {"recorded": False, "reason": "missing_incident_id"}
|
||||
|
||||
history: dict[str, Any] = {
|
||||
"recorded": False,
|
||||
"alert_operation_id": None,
|
||||
"timeline_event_id": None,
|
||||
}
|
||||
context = _recurrence_handoff_context(payload)
|
||||
allowed = bool(payload.get("allowed"))
|
||||
|
||||
try:
|
||||
from src.repositories.alert_operation_log_repository import (
|
||||
get_alert_operation_log_repository,
|
||||
)
|
||||
|
||||
record = await get_alert_operation_log_repository().append(
|
||||
"ESCALATED",
|
||||
incident_id=incident_id,
|
||||
auto_repair_id=str(payload.get("auto_repair_id") or "") or None,
|
||||
actor="awooop_recurrence_work_item_service",
|
||||
action_detail=(
|
||||
f"recurrence_work_item_handoff:{payload.get('handoff_kind')}"
|
||||
)[:200],
|
||||
success=allowed,
|
||||
context=context,
|
||||
)
|
||||
if record is not None:
|
||||
history["alert_operation_id"] = getattr(record, "id", None)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_recurrence_work_item_handoff_alert_operation_history_failed",
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
try:
|
||||
from src.services.approval_db import get_timeline_service
|
||||
|
||||
event = await get_timeline_service().add_event(
|
||||
event_type="human",
|
||||
status="warning" if allowed else "error",
|
||||
title="AwoooP recurrence work item handoff",
|
||||
description=_recurrence_handoff_history_description(context),
|
||||
actor="awooop_recurrence_work_item_service",
|
||||
actor_role=str(payload.get("handoff_kind") or "handoff"),
|
||||
incident_id=incident_id,
|
||||
)
|
||||
if event:
|
||||
history["timeline_event_id"] = event.get("id")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"awooop_recurrence_work_item_handoff_timeline_history_failed",
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
history["recorded"] = bool(
|
||||
history.get("alert_operation_id") or history.get("timeline_event_id")
|
||||
)
|
||||
if not history["recorded"]:
|
||||
history["reason"] = "history_sink_unavailable"
|
||||
return history
|
||||
|
||||
|
||||
def _recurrence_history_description(context: dict[str, Any]) -> str:
|
||||
state = context.get("current_state_summary") or {}
|
||||
route = context.get("read_model_route") or {}
|
||||
@@ -778,6 +935,23 @@ def _recurrence_history_description(context: dict[str, Any]) -> str:
|
||||
)[:500]
|
||||
|
||||
|
||||
def _recurrence_handoff_history_description(context: dict[str, Any]) -> str:
|
||||
state = context.get("current_state_summary") or {}
|
||||
route = context.get("read_model_route") or {}
|
||||
return (
|
||||
f"handoff={context.get('handoff_kind')} "
|
||||
f"status={context.get('handoff_status')} "
|
||||
f"mode={context.get('mode')} "
|
||||
f"occurrences={state.get('occurrence_total')} "
|
||||
f"repair_status={state.get('repair_status')} "
|
||||
f"route={route.get('agent_id')}/{route.get('tool_name')} "
|
||||
f"external_ticket={context.get('creates_external_ticket')} "
|
||||
f"writes_incident={context.get('writes_incident_state')} "
|
||||
f"writes_auto_repair={context.get('writes_auto_repair_result')} "
|
||||
f"writes_ticket={context.get('writes_ticket')}"
|
||||
)[:500]
|
||||
|
||||
|
||||
def build_dossier_coverage(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
@@ -1247,3 +1421,33 @@ async def fetch_recurrence_work_item_dry_run(
|
||||
)
|
||||
payload["history"] = await _record_recurrence_work_item_dry_run_history(payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def fetch_recurrence_work_item_handoff(
|
||||
*,
|
||||
project_id: str | None,
|
||||
work_item_id: str,
|
||||
mode: RecurrenceWorkItemMode = "auto",
|
||||
handoff_kind: RecurrenceWorkItemHandoffKind = "ticket_proposal",
|
||||
provider: str | None = None,
|
||||
limit: int = _MAX_RECURRENCE_EVENTS,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch and record a safe handoff proposal for a recurrence work item."""
|
||||
|
||||
recurrence = await fetch_channel_event_dossier_recurrence(
|
||||
project_id=project_id,
|
||||
provider=provider,
|
||||
limit=limit,
|
||||
)
|
||||
payload = build_recurrence_work_item_handoff(
|
||||
recurrence,
|
||||
work_item_id=work_item_id,
|
||||
mode=mode,
|
||||
handoff_kind=handoff_kind,
|
||||
)
|
||||
payload["history"] = await _record_recurrence_work_item_handoff_history(payload)
|
||||
if payload["history"].get("recorded"):
|
||||
payload["handoff_status"] = "recorded"
|
||||
elif payload.get("allowed"):
|
||||
payload["handoff_status"] = "record_failed"
|
||||
return payload
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.services.channel_event_dossier_service import (
|
||||
build_dossier_event,
|
||||
build_dossier_recurrence,
|
||||
build_recurrence_work_item_dry_run,
|
||||
build_recurrence_work_item_handoff,
|
||||
build_recurrence_work_item_preview,
|
||||
fetch_channel_event_dossier,
|
||||
fetch_channel_event_dossier_coverage,
|
||||
@@ -461,6 +462,68 @@ def test_build_recurrence_work_item_dry_run_returns_ticket_preview_without_write
|
||||
assert dry_run["read_model_route"]["required_scope"] == "read"
|
||||
|
||||
|
||||
def test_build_recurrence_work_item_handoff_records_ticket_proposal_contract() -> None:
|
||||
recurrence = build_dossier_recurrence(
|
||||
[
|
||||
{
|
||||
"event_id": "event-1",
|
||||
"project_id": "awoooi",
|
||||
"channel_type": "internal",
|
||||
"provider_event_id": "alertmanager:received:1",
|
||||
"content_hash": "a" * 64,
|
||||
"content_preview": "Docker container unhealthy",
|
||||
"content_redacted": "Docker container unhealthy",
|
||||
"redaction_version": "audit_sink_v1",
|
||||
"source_envelope": {
|
||||
"provider": "alertmanager",
|
||||
"source_refs": {
|
||||
"alert_ids": ["alert-1"],
|
||||
"incident_ids": ["INC-20260517-F25B4A"],
|
||||
"fingerprints": ["fp-container-unhealthy"],
|
||||
},
|
||||
"log_correlation": {
|
||||
"alertname": "DockerContainerUnhealthy",
|
||||
"severity": "warning",
|
||||
"namespace": "momo",
|
||||
"target_resource": "bitan-pharmacy-bitan-1",
|
||||
"fingerprint": "fp-container-unhealthy",
|
||||
},
|
||||
},
|
||||
"is_duplicate": True,
|
||||
"provider_ts": None,
|
||||
"received_at": "2026-05-17T23:47:00",
|
||||
"run_id": UUID("33333333-3333-4333-8333-333333333333"),
|
||||
"run_state": "completed",
|
||||
"run_agent_id": "openclaw",
|
||||
}
|
||||
],
|
||||
project_id="awoooi",
|
||||
limit=20,
|
||||
)
|
||||
|
||||
handoff = build_recurrence_work_item_handoff(
|
||||
recurrence,
|
||||
work_item_id="incident:INC-20260517-F25B4A",
|
||||
)
|
||||
|
||||
assert handoff["schema_version"] == "awooop_recurrence_work_item_handoff_v1"
|
||||
assert handoff["mode"] == "ticket"
|
||||
assert handoff["handoff_kind"] == "ticket_proposal"
|
||||
assert handoff["handoff_status"] == "ready_to_record"
|
||||
assert handoff["handoff_owner"] == "operator"
|
||||
assert handoff["safety_level"] == "handoff_record_only"
|
||||
assert handoff["allowed"] is True
|
||||
assert handoff["creates_external_ticket"] is False
|
||||
assert handoff["writes_incident_state"] is False
|
||||
assert handoff["writes_auto_repair_result"] is False
|
||||
assert handoff["writes_ticket"] is False
|
||||
assert handoff["ticket_preview"]["would_create"] is False
|
||||
assert handoff["next_step"] == "operator_review_ticket_preview"
|
||||
assert handoff["plan"]["step"] == "record_recurrence_work_item_handoff"
|
||||
assert handoff["plan"]["writes"] == ["timeline_events", "alert_operation_log"]
|
||||
assert handoff["read_model_route"]["required_scope"] == "record_history"
|
||||
|
||||
|
||||
def test_build_recurrence_work_item_preview_raises_for_missing_item() -> None:
|
||||
recurrence = build_dossier_recurrence(
|
||||
[],
|
||||
|
||||
@@ -1846,13 +1846,17 @@
|
||||
"previewing": "Previewing",
|
||||
"dryRun": "Dry-run",
|
||||
"dryRunning": "Dry-running",
|
||||
"failed": "The safe preview / dry-run API did not respond, so the next step cannot be claimed.",
|
||||
"handoff": "Handoff",
|
||||
"handoffing": "Handing off",
|
||||
"failed": "The safe preview / dry-run / handoff API did not respond, so the next step cannot be claimed.",
|
||||
"allowed": "Safety gate passed",
|
||||
"blocked": "Safety gate blocked",
|
||||
"mode": "Mode: {mode}",
|
||||
"previewResult": "Result: {result}",
|
||||
"writes": "Writes: incident={incident}; autoRepair={autoRepair}; ticket={ticket}",
|
||||
"history": "Dry-run stored: {recorded}",
|
||||
"handoffStatus": "Handoff: {kind} / {status}",
|
||||
"externalTicket": "External ticket created: {created}",
|
||||
"ticket": "Ticket preview: {title}",
|
||||
"modes": {
|
||||
"auto": "Auto select",
|
||||
@@ -1862,6 +1866,18 @@
|
||||
"observe": "Observe",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"handoffKinds": {
|
||||
"ticket_proposal": "Ticket proposal",
|
||||
"manual_review": "Manual review",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"handoffStatuses": {
|
||||
"ready_to_record": "Ready to record",
|
||||
"recorded": "Recorded",
|
||||
"record_failed": "Record failed",
|
||||
"blocked": "Blocked",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"previews": {
|
||||
"ticket_preview_ready": "Ticket preview ready",
|
||||
"reverify_preview_ready": "Reverify preview ready",
|
||||
|
||||
@@ -1847,13 +1847,17 @@
|
||||
"previewing": "預覽中",
|
||||
"dryRun": "乾跑",
|
||||
"dryRunning": "乾跑中",
|
||||
"failed": "安全預覽 / 乾跑 API 未回應,不能判定下一步。",
|
||||
"handoff": "交接",
|
||||
"handoffing": "交接中",
|
||||
"failed": "安全預覽 / 乾跑 / 交接 API 未回應,不能判定下一步。",
|
||||
"allowed": "安全閘門通過",
|
||||
"blocked": "安全閘門阻塞",
|
||||
"mode": "模式:{mode}",
|
||||
"previewResult": "結果:{result}",
|
||||
"writes": "寫入:incident={incident};autoRepair={autoRepair};ticket={ticket}",
|
||||
"history": "試跑入庫:{recorded}",
|
||||
"handoffStatus": "交接:{kind} / {status}",
|
||||
"externalTicket": "外部 Ticket 建立:{created}",
|
||||
"ticket": "Ticket 預覽:{title}",
|
||||
"modes": {
|
||||
"auto": "自動選擇",
|
||||
@@ -1863,6 +1867,18 @@
|
||||
"observe": "觀察",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"handoffKinds": {
|
||||
"ticket_proposal": "Ticket 提案",
|
||||
"manual_review": "人工覆核",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"handoffStatuses": {
|
||||
"ready_to_record": "待寫入歷史",
|
||||
"recorded": "已寫入歷史",
|
||||
"record_failed": "寫入失敗",
|
||||
"blocked": "已阻塞",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"previews": {
|
||||
"ticket_preview_ready": "Ticket 預覽已就緒",
|
||||
"reverify_preview_ready": "重新驗證預覽已就緒",
|
||||
|
||||
@@ -110,12 +110,16 @@ type RecurrenceWorkItemActionResult = {
|
||||
incident_id?: string | null;
|
||||
mode?: string | null;
|
||||
requested_mode?: string | null;
|
||||
handoff_kind?: string | null;
|
||||
handoff_status?: string | null;
|
||||
handoff_owner?: string | null;
|
||||
allowed?: boolean | null;
|
||||
executed?: boolean | null;
|
||||
safety_level?: string | null;
|
||||
writes_incident_state?: boolean | null;
|
||||
writes_auto_repair_result?: boolean | null;
|
||||
writes_ticket?: boolean | null;
|
||||
creates_external_ticket?: boolean | null;
|
||||
verification_result_preview?: string | null;
|
||||
next_step?: string | null;
|
||||
checks?: Array<{ name?: string | null; passed?: boolean | null; detail?: string | null }>;
|
||||
@@ -153,7 +157,7 @@ type RecurrenceWorkItemActionResult = {
|
||||
};
|
||||
|
||||
type RecurrenceWorkItemActionState = {
|
||||
loading?: "preview" | "dryRun" | null;
|
||||
loading?: "preview" | "dryRun" | "handoff" | null;
|
||||
result?: RecurrenceWorkItemActionResult | null;
|
||||
error?: string | null;
|
||||
};
|
||||
@@ -331,6 +335,25 @@ function recurrencePreviewKey(preview?: string | null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function recurrenceHandoffStatusKey(status?: string | null) {
|
||||
if (
|
||||
status === "ready_to_record" ||
|
||||
status === "recorded" ||
|
||||
status === "record_failed" ||
|
||||
status === "blocked"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function recurrenceHandoffKindKey(kind?: string | null) {
|
||||
if (kind === "ticket_proposal" || kind === "manual_review") {
|
||||
return kind;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function buildWorkItems(
|
||||
telemetry: Telemetry,
|
||||
t: ReturnType<typeof useTranslations>
|
||||
@@ -653,7 +676,7 @@ function RecurrenceWorkQueuePanel({
|
||||
const summary = recurrence?.summary;
|
||||
const runWorkItemAction = useCallback(async (
|
||||
workItemId: string,
|
||||
action: "preview" | "dryRun"
|
||||
action: "preview" | "dryRun" | "handoff"
|
||||
) => {
|
||||
setActionState((current) => ({
|
||||
...current,
|
||||
@@ -662,21 +685,36 @@ function RecurrenceWorkQueuePanel({
|
||||
|
||||
const encodedProjectId = encodeURIComponent(projectId);
|
||||
const encodedWorkItemId = encodeURIComponent(workItemId);
|
||||
const result = action === "preview"
|
||||
? await fetchJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`,
|
||||
12000
|
||||
)
|
||||
: await postJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`,
|
||||
{
|
||||
project_id: projectId,
|
||||
work_item_id: workItemId,
|
||||
mode: "auto",
|
||||
limit: 300,
|
||||
},
|
||||
15000
|
||||
);
|
||||
let result: RecurrenceWorkItemActionResult | null = null;
|
||||
if (action === "preview") {
|
||||
result = await fetchJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`,
|
||||
12000
|
||||
);
|
||||
} else if (action === "dryRun") {
|
||||
result = await postJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`,
|
||||
{
|
||||
project_id: projectId,
|
||||
work_item_id: workItemId,
|
||||
mode: "auto",
|
||||
limit: 300,
|
||||
},
|
||||
15000
|
||||
);
|
||||
} else {
|
||||
result = await postJson<RecurrenceWorkItemActionResult>(
|
||||
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/handoff`,
|
||||
{
|
||||
project_id: projectId,
|
||||
work_item_id: workItemId,
|
||||
mode: "auto",
|
||||
handoff_kind: "ticket_proposal",
|
||||
limit: 300,
|
||||
},
|
||||
15000
|
||||
);
|
||||
}
|
||||
|
||||
setActionState((current) => ({
|
||||
...current,
|
||||
@@ -736,6 +774,8 @@ function RecurrenceWorkQueuePanel({
|
||||
const actionAllowed = actionResult?.allowed === true;
|
||||
const actionModeKey = recurrenceActionModeKey(actionResult?.mode);
|
||||
const previewKey = recurrencePreviewKey(actionResult?.verification_result_preview);
|
||||
const handoffStatusKey = recurrenceHandoffStatusKey(actionResult?.handoff_status);
|
||||
const handoffKindKey = recurrenceHandoffKindKey(actionResult?.handoff_kind);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -802,6 +842,17 @@ function RecurrenceWorkQueuePanel({
|
||||
? t("actions.dryRunning")
|
||||
: t("actions.dryRun")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runWorkItemAction(workItemId, "handoff")}
|
||||
disabled={currentAction?.loading === "handoff"}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{currentAction?.loading === "handoff"
|
||||
? t("actions.handoffing")
|
||||
: t("actions.handoff")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{runHref ? (
|
||||
@@ -863,6 +914,21 @@ function RecurrenceWorkQueuePanel({
|
||||
recorded: String(actionResult.history?.recorded ?? false),
|
||||
})}
|
||||
</p>
|
||||
{actionResult.handoff_status ? (
|
||||
<p>
|
||||
{t("actions.handoffStatus", {
|
||||
status: t(`actions.handoffStatuses.${handoffStatusKey}` as never),
|
||||
kind: t(`actions.handoffKinds.${handoffKindKey}` as never),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{actionResult.creates_external_ticket !== undefined ? (
|
||||
<p>
|
||||
{t("actions.externalTicket", {
|
||||
created: String(actionResult.creates_external_ticket ?? false),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{actionResult.ticket_preview?.title ? (
|
||||
<p className="truncate">
|
||||
{t("actions.ticket", {
|
||||
|
||||
Reference in New Issue
Block a user