feat(tests): on-hold button with reason modal, Jira comment + On Hold transition
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Add is_on_hold, hold_reason, held_at to tests.
|
||||
|
||||
Revision ID: b051
|
||||
Revises: b050
|
||||
Create Date: 2026-06-19
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "b051"
|
||||
down_revision = "b050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tests", sa.Column("is_on_hold", sa.Boolean(), nullable=False, server_default="false"))
|
||||
op.add_column("tests", sa.Column("hold_reason", sa.Text(), nullable=True))
|
||||
op.add_column("tests", sa.Column("held_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tests", "held_at")
|
||||
op.drop_column("tests", "hold_reason")
|
||||
op.drop_column("tests", "is_on_hold")
|
||||
@@ -134,6 +134,11 @@ class Test(Base):
|
||||
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# ── On-hold fields ─────────────────────────────────────────────
|
||||
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
hold_reason = Column(Text, nullable=True)
|
||||
held_at = Column(DateTime, nullable=True)
|
||||
|
||||
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
||||
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from app.schemas.test import (
|
||||
TestBlueValidate,
|
||||
TestClassificationUpdate,
|
||||
TestCreate,
|
||||
TestHold,
|
||||
TestOut,
|
||||
TestRedUpdate,
|
||||
TestRedValidate,
|
||||
@@ -1120,6 +1121,79 @@ def assign_test_operators(
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/hold — place a test on hold (red/blue techs and leads)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/hold", response_model=TestOut)
|
||||
def hold_test(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestHold,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead", "admin")),
|
||||
):
|
||||
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
|
||||
from datetime import datetime as _dt
|
||||
from app.services.jira_service import push_hold_event
|
||||
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
|
||||
HOLDABLE_STATES = ("draft", "red_executing", "blue_evaluating")
|
||||
if test.state not in HOLDABLE_STATES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
|
||||
)
|
||||
|
||||
if test.is_on_hold:
|
||||
raise HTTPException(status_code=400, detail="Test is already on hold")
|
||||
|
||||
test.is_on_hold = True
|
||||
test.hold_reason = payload.reason
|
||||
test.held_at = _dt.utcnow()
|
||||
|
||||
log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason})
|
||||
db.commit()
|
||||
db.refresh(test)
|
||||
|
||||
push_hold_event(db, test, current_user, resuming=False, reason=payload.reason)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/resume — resume a test that was on hold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/resume", response_model=TestOut)
|
||||
def resume_test(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead", "admin")),
|
||||
):
|
||||
"""Resume a test that was placed on hold."""
|
||||
from app.services.jira_service import push_hold_event
|
||||
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
|
||||
if not test.is_on_hold:
|
||||
raise HTTPException(status_code=400, detail="Test is not on hold")
|
||||
|
||||
test.is_on_hold = False
|
||||
test.hold_reason = None
|
||||
test.held_at = None
|
||||
|
||||
log_action(db, current_user.id, "resume_test", str(test_id), {})
|
||||
db.commit()
|
||||
db.refresh(test)
|
||||
|
||||
push_hold_event(db, test, current_user, resuming=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tests/{id}/remediation — update remediation fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,6 +141,12 @@ class TestAssign(BaseModel):
|
||||
blue_tech_assignee: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestHold(BaseModel):
|
||||
"""Payload for placing a test on hold."""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
# ── Legacy validate (kept for backwards compat) ────────────────────
|
||||
|
||||
|
||||
@@ -232,6 +238,11 @@ class TestOut(BaseModel):
|
||||
red_tech_assignee: uuid.UUID | None = None
|
||||
blue_tech_assignee: uuid.UUID | None = None
|
||||
|
||||
# On-hold fields
|
||||
is_on_hold: bool = False
|
||||
hold_reason: str | None = None
|
||||
held_at: datetime | None = None
|
||||
|
||||
# Re-test fields
|
||||
retest_of: uuid.UUID | None = None
|
||||
# Assign retest_count = 0
|
||||
|
||||
@@ -633,6 +633,73 @@ def push_test_event(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-hold Jira notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def push_hold_event(
|
||||
db: Session,
|
||||
test,
|
||||
actor,
|
||||
*,
|
||||
resuming: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Post an on-hold / resume comment to the Jira issue linked to *test*.
|
||||
|
||||
Non-fatal — any Jira error is logged and swallowed.
|
||||
"""
|
||||
if not has_jira_configured(actor, db):
|
||||
return
|
||||
|
||||
link = (
|
||||
db.query(JiraLink)
|
||||
.filter(
|
||||
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||
JiraLink.entity_id == test.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not link:
|
||||
return
|
||||
|
||||
try:
|
||||
jira = get_user_jira_client(actor, db)
|
||||
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
if resuming:
|
||||
comment = (
|
||||
f"h3. ▶ Test Resumed\n\n"
|
||||
f"*Resumed by:* {actor.username}\n"
|
||||
f"*At:* {ts}\n\n"
|
||||
f"_The test has been taken off hold and is active again._"
|
||||
)
|
||||
try:
|
||||
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
||||
except Exception as exc_t:
|
||||
logger.warning("Could not transition %s off hold: %s", link.jira_issue_key, exc_t)
|
||||
else:
|
||||
comment = (
|
||||
f"h3. ⏸ Test Placed On Hold\n\n"
|
||||
f"*Placed on hold by:* {actor.username}\n"
|
||||
f"*At:* {ts}\n"
|
||||
f"*Reason:* {reason or 'No reason provided'}\n\n"
|
||||
f"_This test has been paused. No action required until it is resumed._"
|
||||
)
|
||||
try:
|
||||
jira.set_issue_status(link.jira_issue_key, "On Hold")
|
||||
except Exception as exc_t:
|
||||
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
|
||||
|
||||
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
db.flush()
|
||||
logger.info("Posted hold event to Jira %s (resuming=%s)", link.jira_issue_key, resuming)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to push hold event for test %s: %s", test.id, exc, exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy / generic helpers (kept for existing routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user