generated from coulomb/repo-seed
feat(capability-requests): add routing_note, PATCH endpoint, word-boundary fix, and ops-bridge tunnel targets
- Add `routing_note` column (migration l9g0h1i2j3k4) to persist why a request was routed to a given domain
- Fix substring-match bug in `_route_capability`: use `\b` word-boundary regex so 'postgres' no longer matches inside 'postgresql'
- Include `title` in keyword scoring for better routing accuracy
- Return `routing_note` string from `_route_capability` and store it on the request
- Add `PATCH /capability-requests/{id}` endpoint + `CapabilityRequestPatch` schema to correct mutable metadata (catalog_entry_id, priority, blocking_task_id, fulfilling_workstream_id)
- Add `patch_capability_request` MCP tool wrapping the new endpoint
- Add 105 lines of routing tests (word-boundary, title-match, multi-entry scoring, broadcast fallback)
- Add `tunnels-up`, `tunnels-status`, `tunnels-check` Makefile targets for ops-bridge managed tunnels
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
Makefile
15
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install install-cli db db-tools migrate seed api dashboard check test clean register-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnel tunnel-daemon tunnel-loop tunnel-status tunnel-stop install-hooks install-hooks-all gitea-inventory
|
.PHONY: install install-cli db db-tools migrate seed api dashboard check test clean register-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnel tunnel-daemon tunnel-loop tunnel-status tunnel-stop tunnels-up tunnels-status tunnels-check install-hooks install-hooks-all gitea-inventory
|
||||||
|
|
||||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||||
|
|
||||||
@@ -103,6 +103,19 @@ tunnel-stop:
|
|||||||
@pkill -f "autossh.*$(TUNNEL_PORT)" 2>/dev/null && echo "autossh stopped" || true
|
@pkill -f "autossh.*$(TUNNEL_PORT)" 2>/dev/null && echo "autossh stopped" || true
|
||||||
@pkill -f "ssh.*-R $(TUNNEL_PORT)" 2>/dev/null && echo "ssh loop stopped" || true
|
@pkill -f "ssh.*-R $(TUNNEL_PORT)" 2>/dev/null && echo "ssh loop stopped" || true
|
||||||
|
|
||||||
|
## ops-bridge managed tunnels (preferred over tunnel-*/tunnel-daemon)
|
||||||
|
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||||
|
tunnels-up:
|
||||||
|
bridge up
|
||||||
|
|
||||||
|
tunnels-status:
|
||||||
|
bridge status
|
||||||
|
|
||||||
|
## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE.
|
||||||
|
## Exits non-zero if any tunnel is not fully operational.
|
||||||
|
tunnels-check:
|
||||||
|
bridge check
|
||||||
|
|
||||||
## Start (or restart) the full backend — db + migrate + uvicorn.
|
## Start (or restart) the full backend — db + migrate + uvicorn.
|
||||||
## Stops uvicorn on :8000 if already running, then starts fresh.
|
## Stops uvicorn on :8000 if already running, then starts fresh.
|
||||||
api: db
|
api: db
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
accepted_at: Mapped[datetime | None] = mapped_column(
|
accepted_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from api.schemas.capability_request import (
|
|||||||
CatalogRead,
|
CatalogRead,
|
||||||
CapabilityRequestAccept,
|
CapabilityRequestAccept,
|
||||||
CapabilityRequestCreate,
|
CapabilityRequestCreate,
|
||||||
|
CapabilityRequestPatch,
|
||||||
CapabilityRequestRead,
|
CapabilityRequestRead,
|
||||||
CapabilityRequestStatusPatch,
|
CapabilityRequestStatusPatch,
|
||||||
)
|
)
|
||||||
@@ -100,8 +102,8 @@ async def create_request(
|
|||||||
req_domain = await _resolve_domain(body.requesting_domain, session)
|
req_domain = await _resolve_domain(body.requesting_domain, session)
|
||||||
|
|
||||||
# Route to provider
|
# Route to provider
|
||||||
fulfilling_domain_id, catalog_entry_id = await _route_capability(
|
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
|
||||||
session, body.capability_type, body.description or ""
|
session, body.capability_type, body.title, body.description or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
req = CapabilityRequest(
|
req = CapabilityRequest(
|
||||||
@@ -115,6 +117,7 @@ async def create_request(
|
|||||||
blocking_task_id=body.blocking_task_id,
|
blocking_task_id=body.blocking_task_id,
|
||||||
fulfilling_domain_id=fulfilling_domain_id,
|
fulfilling_domain_id=fulfilling_domain_id,
|
||||||
catalog_entry_id=catalog_entry_id,
|
catalog_entry_id=catalog_entry_id,
|
||||||
|
routing_note=routing_note,
|
||||||
)
|
)
|
||||||
session.add(req)
|
session.add(req)
|
||||||
await session.flush() # get req.id before creating notification
|
await session.flush() # get req.id before creating notification
|
||||||
@@ -277,16 +280,70 @@ async def patch_request_status(
|
|||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||||
|
async def patch_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: CapabilityRequestPatch,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
||||||
|
priority, blocking_task_id, fulfilling_workstream_id.
|
||||||
|
Only fields present in the request body (non-None) are updated.
|
||||||
|
"""
|
||||||
|
req = await _get_request_or_404(request_id, session)
|
||||||
|
|
||||||
|
corrections: list[str] = []
|
||||||
|
|
||||||
|
if body.catalog_entry_id is not None:
|
||||||
|
old_entry_id = req.catalog_entry_id
|
||||||
|
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||||
|
req.catalog_entry_id = entry.id
|
||||||
|
# Re-derive fulfilling domain from catalog entry
|
||||||
|
old_domain_id = req.fulfilling_domain_id
|
||||||
|
req.fulfilling_domain_id = entry.domain_id
|
||||||
|
corrections.append(
|
||||||
|
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
||||||
|
f"fulfilling_domain re-derived → {entry.domain_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.priority is not None:
|
||||||
|
req.priority = body.priority
|
||||||
|
corrections.append(f"priority → {body.priority}")
|
||||||
|
|
||||||
|
if body.blocking_task_id is not None:
|
||||||
|
req.blocking_task_id = body.blocking_task_id
|
||||||
|
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
||||||
|
|
||||||
|
if body.fulfilling_workstream_id is not None:
|
||||||
|
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||||
|
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
|
||||||
|
|
||||||
|
if not corrections:
|
||||||
|
return req # no-op
|
||||||
|
|
||||||
|
correction_note = "hub correction: " + "; ".join(corrections)
|
||||||
|
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routing algorithm
|
# Routing algorithm
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _route_capability(
|
async def _route_capability(
|
||||||
session: AsyncSession, capability_type: str, description: str
|
session: AsyncSession, capability_type: str, title: str, description: str
|
||||||
) -> tuple[uuid.UUID | None, uuid.UUID | None]:
|
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
||||||
"""Find the best-matching domain for a capability request.
|
"""Find the best-matching catalog entry for a capability request.
|
||||||
|
|
||||||
Returns (domain_id, catalog_entry_id) or (None, None) for broadcast.
|
Returns (domain_id, catalog_entry_id, routing_note).
|
||||||
|
Uses word-boundary matching on (title + description) combined to avoid
|
||||||
|
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
|
||||||
|
'ha' inside 'has').
|
||||||
"""
|
"""
|
||||||
q = select(CapabilityCatalog).where(
|
q = select(CapabilityCatalog).where(
|
||||||
CapabilityCatalog.capability_type == capability_type,
|
CapabilityCatalog.capability_type == capability_type,
|
||||||
@@ -294,20 +351,41 @@ async def _route_capability(
|
|||||||
)
|
)
|
||||||
entries = list((await session.execute(q)).scalars().all())
|
entries = list((await session.execute(q)).scalars().all())
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
||||||
|
|
||||||
if len(entries) == 1:
|
if len(entries) == 1:
|
||||||
return entries[0].domain_id, entries[0].id
|
e = entries[0]
|
||||||
|
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
|
||||||
|
|
||||||
if len(entries) > 1 and description:
|
# Score by word-boundary keyword overlap against title + description combined
|
||||||
desc_lower = description.lower()
|
combined = f"{title} {description or ''}".lower()
|
||||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
score = sum(1 for kw in (entry.keywords or []) if kw.lower() in desc_lower)
|
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
||||||
scored.append((score, entry))
|
score = sum(
|
||||||
scored.sort(key=lambda x: -x[0])
|
1 for kw in keywords
|
||||||
if scored[0][0] > 0 and (len(scored) < 2 or scored[0][0] > scored[1][0]):
|
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
|
||||||
return scored[0][1].domain_id, scored[0][1].id
|
)
|
||||||
|
scored.append((score, entry))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
|
||||||
return None, None
|
best_score, best = scored[0]
|
||||||
|
if best_score == 0:
|
||||||
|
return None, None, (
|
||||||
|
f"no keyword overlap for type '{capability_type}' among "
|
||||||
|
f"{len(entries)} entries — broadcast"
|
||||||
|
)
|
||||||
|
if len(scored) >= 2 and scored[1][0] == best_score:
|
||||||
|
return None, None, (
|
||||||
|
f"ambiguous routing: '{scored[0][1].title}' and '{scored[1][1].title}' "
|
||||||
|
f"both scored {best_score} — broadcast"
|
||||||
|
)
|
||||||
|
|
||||||
|
return best.domain_id, best.id, (
|
||||||
|
f"matched '{best.title}' (score={best_score}, "
|
||||||
|
f"keywords matched from: {title!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ class CapabilityRequestStatusPatch(BaseModel):
|
|||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestPatch(BaseModel):
|
||||||
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
|
priority: str | None = None
|
||||||
|
blocking_task_id: uuid.UUID | None = None
|
||||||
|
fulfilling_workstream_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestRead(BaseModel):
|
class CapabilityRequestRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ class CapabilityRequestRead(BaseModel):
|
|||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
catalog_entry_id: uuid.UUID | None = None
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
resolution_note: str | None = None
|
resolution_note: str | None = None
|
||||||
|
routing_note: str | None = None
|
||||||
accepted_at: datetime | None = None
|
accepted_at: datetime | None = None
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -1881,6 +1881,46 @@ def update_capability_request_status(
|
|||||||
return json.dumps(result, indent=2)
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def patch_capability_request(
|
||||||
|
request_id: str,
|
||||||
|
catalog_entry_id: Optional[str] = None,
|
||||||
|
priority: Optional[str] = None,
|
||||||
|
blocking_task_id: Optional[str] = None,
|
||||||
|
fulfilling_workstream_id: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Correct mutable metadata on a capability request.
|
||||||
|
|
||||||
|
Correcting catalog_entry_id automatically re-derives the fulfilling domain.
|
||||||
|
Use this when the hub mis-routed a request (wrong catalog entry or domain).
|
||||||
|
Only provided (non-None) fields are updated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: UUID of the capability request to patch.
|
||||||
|
catalog_entry_id: Correct catalog entry UUID. Re-derives fulfilling domain.
|
||||||
|
priority: New priority (low/medium/high/critical).
|
||||||
|
blocking_task_id: UUID of the task this request unblocks on completion.
|
||||||
|
fulfilling_workstream_id: UUID of the workstream delivering this capability.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated capability request dict, or {"error": "..."}.
|
||||||
|
"""
|
||||||
|
body: dict = {}
|
||||||
|
if catalog_entry_id is not None:
|
||||||
|
body["catalog_entry_id"] = catalog_entry_id
|
||||||
|
if priority is not None:
|
||||||
|
body["priority"] = priority
|
||||||
|
if blocking_task_id is not None:
|
||||||
|
body["blocking_task_id"] = blocking_task_id
|
||||||
|
if fulfilling_workstream_id is not None:
|
||||||
|
body["fulfilling_workstream_id"] = fulfilling_workstream_id
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
return {"error": "no fields provided to patch"}
|
||||||
|
|
||||||
|
return json.dumps(_patch(f"/capability-requests/{request_id}", body), indent=2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def list_capability_requests(
|
def list_capability_requests(
|
||||||
domain: str | None = None,
|
domain: str | None = None,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Add routing_note to capability_requests
|
||||||
|
|
||||||
|
Revision ID: l9g0h1i2j3k4
|
||||||
|
Revises: k8f9a0b1c2d3
|
||||||
|
Create Date: 2026-03-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'l9g0h1i2j3k4'
|
||||||
|
down_revision = 'k8f9a0b1c2d3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('capability_requests', sa.Column('routing_note', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('capability_requests', 'routing_note')
|
||||||
@@ -335,3 +335,108 @@ class TestCapabilityRequestLifecycle:
|
|||||||
"status": "requested",
|
"status": "requested",
|
||||||
})
|
})
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityRequestRouting:
|
||||||
|
async def test_word_boundary_avoids_substring_false_positive(self, client):
|
||||||
|
"""'postgres' keyword must not match inside 'postgresql'."""
|
||||||
|
await _setup_two_domains(client)
|
||||||
|
# Register two entries: PostgreSQL HA (keywords: postgres, ha) and k3s (keywords: k3s, cluster)
|
||||||
|
await _register_catalog(client, domain="railiance", cap_type="infrastructure",
|
||||||
|
title="PostgreSQL HA", keywords=["postgresql", "postgres", "ha"])
|
||||||
|
await _register_catalog(client, domain="custodian", cap_type="infrastructure",
|
||||||
|
title="K3s provisioning", keywords=["k3s", "cluster", "k8s"])
|
||||||
|
|
||||||
|
# Description mentions k8s and cluster but NOT standalone "postgres" or "ha"
|
||||||
|
req = await _create_request(
|
||||||
|
client,
|
||||||
|
title="K3s cluster access",
|
||||||
|
description="Need k8s foundations. cluster must be up. kubeconfig required.",
|
||||||
|
cap_type="infrastructure",
|
||||||
|
)
|
||||||
|
# k3s entry should win (k8s + cluster match), not postgres entry
|
||||||
|
assert req["catalog_entry_id"] is not None
|
||||||
|
# Routing note should mention k3s
|
||||||
|
assert req["routing_note"] is not None
|
||||||
|
assert "K3s" in req["routing_note"] or "k3s" in req["routing_note"].lower()
|
||||||
|
|
||||||
|
async def test_title_included_in_routing(self, client):
|
||||||
|
"""Title keywords should contribute to routing score."""
|
||||||
|
await _setup_two_domains(client)
|
||||||
|
await _register_catalog(client, domain="railiance", cap_type="infrastructure",
|
||||||
|
title="K3s cluster", keywords=["k3s", "cluster", "kubernetes"])
|
||||||
|
await _register_catalog(client, domain="custodian", cap_type="infrastructure",
|
||||||
|
title="Postgres DB", keywords=["postgresql", "postgres", "database"])
|
||||||
|
|
||||||
|
# Title contains "k3s" but description is generic
|
||||||
|
req = await _create_request(
|
||||||
|
client,
|
||||||
|
title="k3s cluster access needed",
|
||||||
|
description="Need access to proceed with deployment.",
|
||||||
|
cap_type="infrastructure",
|
||||||
|
)
|
||||||
|
assert req["routing_note"] is not None
|
||||||
|
assert req["catalog_entry_id"] is not None
|
||||||
|
# Should match k3s (title has "k3s" and "cluster")
|
||||||
|
# Verify via routing note
|
||||||
|
assert "K3s" in req["routing_note"] or "k3s" in req["routing_note"].lower()
|
||||||
|
|
||||||
|
async def test_ambiguous_routing_broadcasts(self, client):
|
||||||
|
"""Tied scores should broadcast (no fulfilling domain)."""
|
||||||
|
await _setup_two_domains(client)
|
||||||
|
await _register_catalog(client, domain="railiance", cap_type="infrastructure",
|
||||||
|
title="Entry A", keywords=["cluster"])
|
||||||
|
await _register_catalog(client, domain="custodian", cap_type="infrastructure",
|
||||||
|
title="Entry B", keywords=["cluster"])
|
||||||
|
req = await _create_request(
|
||||||
|
client,
|
||||||
|
title="cluster needed",
|
||||||
|
description="cluster access",
|
||||||
|
cap_type="infrastructure",
|
||||||
|
)
|
||||||
|
assert req["fulfilling_domain_slug"] is None
|
||||||
|
assert "ambiguous" in req["routing_note"]
|
||||||
|
|
||||||
|
async def test_patch_corrects_catalog_entry_and_reroutes(self, client):
|
||||||
|
"""PATCH /capability-requests/{id} corrects catalog_entry_id and re-derives domain."""
|
||||||
|
req_d, ful_d = await _setup_two_domains(client)
|
||||||
|
# Register wrong entry (postgres) and correct entry (k3s)
|
||||||
|
wrong = await _register_catalog(client, domain="railiance", cap_type="infrastructure",
|
||||||
|
title="PostgreSQL HA", keywords=["postgresql"])
|
||||||
|
correct = await _register_catalog(client, domain="custodian", cap_type="infrastructure",
|
||||||
|
title="K3s cluster", keywords=["k3s"])
|
||||||
|
|
||||||
|
# Create request — auto-routes to postgres (only postgres keyword matches "postgresql" in description)
|
||||||
|
req = await _create_request(
|
||||||
|
client,
|
||||||
|
description="Need postgresql and k3s access",
|
||||||
|
cap_type="infrastructure",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch to correct catalog entry
|
||||||
|
r = await client.patch(f"/capability-requests/{req['id']}", json={
|
||||||
|
"catalog_entry_id": correct["id"],
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["catalog_entry_id"] == correct["id"]
|
||||||
|
assert data["fulfilling_domain_slug"] == "custodian"
|
||||||
|
assert "hub correction" in data["routing_note"]
|
||||||
|
assert "K3s" in data["routing_note"]
|
||||||
|
|
||||||
|
async def test_patch_unknown_catalog_entry_404(self, client):
|
||||||
|
await _setup_two_domains(client)
|
||||||
|
req = await _create_request(client, cap_type="security")
|
||||||
|
import uuid as _uuid
|
||||||
|
r = await client.patch(f"/capability-requests/{req['id']}", json={
|
||||||
|
"catalog_entry_id": str(_uuid.uuid4()),
|
||||||
|
})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
async def test_patch_priority(self, client):
|
||||||
|
await _setup_two_domains(client)
|
||||||
|
req = await _create_request(client, cap_type="security")
|
||||||
|
r = await client.patch(f"/capability-requests/{req['id']}", json={"priority": "critical"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["priority"] == "critical"
|
||||||
|
assert "priority" in r.json()["routing_note"]
|
||||||
|
|||||||
Reference in New Issue
Block a user