feat(tests): disputed state + fix timestamps on reopen
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

1. New 'disputed' state — one lead approved, the other rejected:
   - Both approved → validated (unchanged)
   - Both rejected → rejected (unchanged)
   - One approves + one rejects → disputed (new)
   - DB: ALTER TYPE teststate ADD VALUE 'disputed'
   - Notification sent to the approving lead explaining the conflict
     with the rejection notes

2. Disputed UI in TestDetailHeader:
   - Amber banner showing conflict + rejection reason from notes
   - 'Change Vote to Rejected' button for the lead who approved
   - Validation indicators shown for disputed state too

3. Fix timestamps on reopen (rejected → draft):
   - Keep red_started_at, blue_started_at etc. as historical record
   - Only clear paused_at defensively
   - Timestamps naturally update when test is re-executed

4. disputed badge (amber) added to all badge color maps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-03 12:21:47 +02:00
parent 2de95a3082
commit 61e6037e97
11 changed files with 166 additions and 21 deletions

View File

@@ -0,0 +1,22 @@
"""Add 'disputed' value to teststate enum.
Revision ID: b046
Revises: b045
Create Date: 2026-06-03
"""
from alembic import op
revision = "b046"
down_revision = "b045"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'disputed'")
def downgrade() -> None:
# PostgreSQL does not support removing enum values; downgrade is a no-op.
pass

View File

@@ -24,6 +24,7 @@ class TestState(str, enum.Enum):
in_review = "in_review"
validated = "validated"
rejected = "rejected"
disputed = "disputed" # one lead approved, the other rejected
class TeamSide(str, enum.Enum):

View File

@@ -314,21 +314,21 @@ class TestEntity:
def check_dual_validation(self) -> None:
"""Evaluate both leads' votes and advance state if appropriate.
- Both **approved** -> ``validated``
- Either **rejected** -> ``rejected``
- Otherwise no change (waiting for the other lead).
Rules (v2 — consensus required):
- Both **approved** -> ``validated``
- Both **rejected** -> ``rejected``
- One approved + one rejected -> ``disputed`` (conflict, needs discussion)
- Otherwise (one or both still pending) -> no change
Called automatically by :meth:`validate_red` and :meth:`validate_blue`.
Also available as a standalone entry point for backward compatibility
when validation fields are set externally.
"""
self._check_dual_validation()
def _assert_in_review(self, side: str) -> None:
if self.state != TestState.in_review:
if self.state not in (TestState.in_review, TestState.disputed):
raise InvalidOperationError(
f"Cannot validate {side} side while test is in "
f"'{self.state.value}' state (must be in_review)"
f"'{self.state.value}' state (must be in_review or disputed)"
)
@staticmethod
@@ -339,11 +339,19 @@ class TestEntity:
)
def _check_dual_validation(self) -> None:
"""If both leads have voted, advance to validated or rejected."""
"""Advance the test state once both leads have voted."""
r, b = self.red_validation_status, self.blue_validation_status
if r == "rejected" or b == "rejected":
self.state = TestState.rejected
self._events.append(DomainEvent("dual_validation_rejected"))
elif r == "approved" and b == "approved":
if r == "approved" and b == "approved":
self.state = TestState.validated
self._events.append(DomainEvent("dual_validation_approved"))
elif r == "rejected" and b == "rejected":
# Full consensus to reject
self.state = TestState.rejected
self._events.append(DomainEvent("dual_validation_rejected"))
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
# Conflict: one approves, one rejects → needs discussion
self.state = TestState.disputed
self._events.append(DomainEvent("dual_validation_disputed"))

View File

@@ -37,7 +37,8 @@ VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
TestState.draft: [TestState.red_executing],
TestState.red_executing: [TestState.blue_evaluating],
TestState.blue_evaluating: [TestState.in_review],
TestState.in_review: [TestState.validated, TestState.rejected],
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
TestState.disputed: [TestState.validated, TestState.rejected],
TestState.rejected: [TestState.draft],
TestState.validated: [], # terminal state
}
@@ -529,6 +530,60 @@ def _dispatch_dual_validation_effects(
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
elif event.name == "dual_validation_disputed":
# Notify the lead who APPROVED asking them to review the rejection
_notify_validation_conflict(db, test, actor)
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
"""Notify the lead who APPROVED about the other lead's rejection.
Tells them: 'The other lead rejected. Review their notes and either
change your vote to rejected or discuss with them to resolve.'
"""
from app.models.user import User as UserModel
red_approved = test.red_validation_status == "approved"
blue_approved = test.blue_validation_status == "approved"
# Identify who approved (they need to be notified)
approver_id = None
rejector_role = None
rejection_notes = None
if red_approved and test.blue_validation_status == "rejected":
approver_id = test.red_validated_by
rejector_role = "Blue Lead"
rejection_notes = test.blue_validation_notes
elif blue_approved and test.red_validation_status == "rejected":
approver_id = test.blue_validated_by
rejector_role = "Red Lead"
rejection_notes = test.red_validation_notes
if not approver_id:
return
notes_snippet = f': "{rejection_notes[:200]}"' if rejection_notes else ""
try:
create_notification(
db,
user_id=approver_id,
type="validation_conflict",
title="Validation conflict — action required",
message=(
f"{rejector_role} rejected test '{test.name}' while you approved it{notes_snippet}. "
f"Review their reason and either change your decision to 'rejected' "
f"or contact {rejector_role} to resolve the disagreement."
),
entity_type="test",
entity_id=str(test.id),
)
except Exception as e:
logger.warning(
"Failed to send conflict notification for test %s: %s",
test.id, e, exc_info=True,
)
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
"""Create a re-test when remediation is completed.
@@ -681,13 +736,12 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
test.blue_validated_at = None
# test.blue_validation_notes → KEEP (rejection reason / clarification needed)
# Reset phase timing so the new execution starts fresh
test.red_started_at = None
test.blue_started_at = None
test.blue_work_started_at = None
# Phase timing: kept as historical record of the previous attempt.
# When the team presses "Start Execution" again, red_started_at will be
# overwritten with the new timestamp — no manual reset needed.
# Only the active-pause marker is cleared (it should never be set on a
# rejected test, but clear it defensively to avoid a stuck timer).
test.paused_at = None
test.red_paused_seconds = 0
test.blue_paused_seconds = 0
try:
from app.services.jira_service import push_test_event