feat(tests): disputed state + fix timestamps on reopen
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user