feat(a2h2a): add ticket dry-run previews

This commit is contained in:
Chris Christiansen 2026-09-05 19:28:25 +00:00
parent 0d2f1f3b17
commit 8548d41e22
5 changed files with 131 additions and 2 deletions

View File

@ -232,9 +232,10 @@ class A2H2ATicket(BaseModel):
context: Context context: Context
severity: str severity: str
governance: Governance governance: Governance
dry_run: bool = False
def create_a2h2a_ticket(ticket: A2H2ATicket) -> dict: def create_a2h2a_ticket(ticket: A2H2ATicket) -> dict:
"""Create a new A2H2A ticket for a sensitive operation.""" """Create a new A2H2A ticket for a sensitive operation. If dry_run is True, a preview is returned and no ticket is created."""
return _call_tool("create_a2h2a_ticket", ticket.dict()) return _call_tool("create_a2h2a_ticket", ticket.dict())
@ -296,6 +297,12 @@ _INSTRUCTION_TEMPLATE = (
"Når brukaren ber om data — KALL ALLTID rett tool før du svarar. " "Når brukaren ber om data — KALL ALLTID rett tool før du svarar. "
"Svar på norsk (bokmål) med mindre annet er bedt om. " "Svar på norsk (bokmål) med mindre annet er bedt om. "
"HITL: ikkje kjør terraform/deploy utan godkjenning frå Chris." "HITL: ikkje kjør terraform/deploy utan godkjenning frå Chris."
"### A2H2A dry-run safety rule\n"
"If a user requests a dry run, preview, draft, utkast, \"ikke opprett sak\", \"ikke lagre\", \"do not create\", or \"do not save\":\n"
"- call create_a2h2a_ticket with dry_run=true;\n"
"- return the preview only;\n"
"- never claim a ticket was created.\n"
"Set dry_run=false only when the user explicitly asks to create a real ticket."
) )

View File

@ -209,6 +209,7 @@ class A2H2ATicket(BaseModel):
context: Context context: Context
severity: str severity: str
governance: Governance governance: Governance
dry_run: bool = False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# A2H2A (Human-in-the-Loop) # A2H2A (Human-in-the-Loop)

View File

@ -0,0 +1,49 @@
from unittest.mock import patch
from agent import (
A2H2ATicket,
Context,
Governance,
ProposedAction,
TicketSource,
create_a2h2a_ticket,
)
@patch("agent._call_tool")
def test_create_a2h2a_ticket_forwards_dry_run(mock_call_tool):
dry_run_ticket = A2H2ATicket(
dry_run=True,
source=TicketSource(
reporter="test",
trigger="test",
affected_service="test",
project_id="test",
),
proposed_action=ProposedAction(
execution_tool="test",
tool_parameters={},
),
context=Context(
summary="test",
user_justification="test",
conversation_history=[],
),
severity="LOW",
governance=Governance(
approval_status="PENDING",
authorized_approver="test",
requires_mfa=True,
timeout_minutes=15,
),
)
create_a2h2a_ticket(dry_run_ticket)
mock_call_tool.assert_called_once()
tool_name, payload = mock_call_tool.call_args.args
assert tool_name == "create_a2h2a_ticket"
assert payload["dry_run"] is True
assert payload["source"]["reporter"] == "test"
assert payload["proposed_action"]["execution_tool"] == "test"

View File

@ -372,7 +372,22 @@ async def list_open_issues(p): return await _gitea_get(f"/repos/{p.g
async def create_issue(p): return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")}) async def create_issue(p): return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")})
async def create_a2h2a_ticket(p: dict) -> dict: async def create_a2h2a_ticket(p: dict) -> dict:
"""Creates an A2H2A governance ticket.""" preview = {
"source": p.get("source"),
"proposed_action": p.get("proposed_action"),
"context": p.get("context"),
"severity": p.get("severity"),
"governance": p.get("governance"),
"dry_run": bool(p.get("dry_run")),
}
if preview["dry_run"]:
return {
"dry_run": True,
"message": "Ticket preview only; no ticket was created.",
"proposed_ticket": preview,
}
from google.cloud import firestore from google.cloud import firestore
import uuid, hashlib, hmac, os import uuid, hashlib, hmac, os

57
opax-mcp/test_dry_run.py Normal file
View File

@ -0,0 +1,57 @@
import pytest
from unittest.mock import patch, MagicMock
# Mock the firestore client before it's imported by the server
@patch("google.cloud.firestore.Client")
def test_create_a2h2a_ticket_dry_run(mock_firestore_client):
from server import create_a2h2a_ticket
import asyncio
mock_db = MagicMock()
mock_firestore_client.return_value = mock_db
dry_run_payload = {
"dry_run": True,
"source": {"reporter": "test", "trigger": "test", "affected_service": "test", "project_id": "test"},
"proposed_action": {"execution_tool": "test", "tool_parameters": {}},
"context": {"summary": "test", "user_justification": "test", "conversation_history": []},
"severity": "LOW",
"governance": {"approval_status": "PENDING", "authorized_approver": "test", "requires_mfa": True, "timeout_minutes": 15}
}
result = asyncio.run(create_a2h2a_ticket(dry_run_payload))
assert result["dry_run"] is True
assert result["message"] == "Ticket preview only; no ticket was created."
assert "proposed_ticket" in result
mock_db.collection.assert_not_called()
@patch("google.cloud.firestore.Client")
def test_create_a2h2a_ticket_live_run(mock_firestore_client):
from server import create_a2h2a_ticket
import asyncio
mock_db = MagicMock()
mock_firestore_client.return_value = mock_db
mock_collection = MagicMock()
mock_db.collection.return_value = mock_collection
mock_document = MagicMock()
mock_collection.document.return_value = mock_document
live_run_payload = {
"dry_run": False,
"source": {"reporter": "test", "trigger": "test", "affected_service": "test", "project_id": "test"},
"proposed_action": {"execution_tool": "test", "tool_parameters": {}},
"context": {"summary": "test", "user_justification": "test", "conversation_history": []},
"severity": "LOW",
"governance": {"approval_status": "PENDING", "authorized_approver": "test", "requires_mfa": True, "timeout_minutes": 15}
}
result = asyncio.run(create_a2h2a_ticket(live_run_payload))
assert result["status"] == "created"
assert "ticket_id" in result
assert "review_url" in result
mock_db.collection.assert_called_once_with("a2h2a_tickets")
mock_collection.document.assert_called_once()
mock_document.set.assert_called_once()