feat(tests): load-balanced reviewer selection and Jira reviewer sync
This commit is contained in:
@@ -307,7 +307,9 @@ _SEVERITY_TO_PRIORITY: dict[str, str] = {
|
||||
_STATE_EMOJI: dict[str, str] = {
|
||||
"draft": "📝 Draft",
|
||||
"red_executing": "🔴 Red Team Executing",
|
||||
"red_review": "🔎 Red Lead Review",
|
||||
"blue_evaluating": "🔵 Blue Team Evaluating",
|
||||
"blue_review": "🔎 Blue Lead Review",
|
||||
"in_review": "📋 In Review",
|
||||
"validated": "✅ Validated",
|
||||
"rejected": "❌ Rejected",
|
||||
@@ -370,6 +372,18 @@ def _build_state_comment(
|
||||
"Red Team has started the attack execution.",
|
||||
]
|
||||
|
||||
elif new_state == "red_review":
|
||||
lines += [
|
||||
"Red Team has submitted evidence and the test is awaiting Red Lead review "
|
||||
"before it queues for Blue Team.",
|
||||
]
|
||||
|
||||
elif new_state == "blue_review":
|
||||
lines += [
|
||||
"Blue Team has submitted evidence and the test is awaiting Blue Lead review "
|
||||
"before cross-validation.",
|
||||
]
|
||||
|
||||
elif new_state == "blue_evaluating":
|
||||
lines += [
|
||||
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
|
||||
@@ -667,6 +681,7 @@ def push_test_event(
|
||||
new_state: str,
|
||||
*,
|
||||
extra: dict | None = None,
|
||||
assignee: User | None = None,
|
||||
) -> None:
|
||||
"""Post a lifecycle comment to the Jira issue linked to *test*.
|
||||
|
||||
@@ -720,6 +735,21 @@ def push_test_event(
|
||||
link.jira_issue_key, jira_account_id, exc_a,
|
||||
)
|
||||
|
||||
if new_state in ("red_review", "blue_review") and assignee:
|
||||
jira_account_id = getattr(assignee, "jira_account_id", None)
|
||||
if jira_account_id:
|
||||
try:
|
||||
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||
logger.info(
|
||||
"Assigned Jira ticket %s to reviewer account %s",
|
||||
link.jira_issue_key, jira_account_id,
|
||||
)
|
||||
except Exception as exc_a:
|
||||
logger.warning(
|
||||
"Could not assign %s to reviewer %s: %s",
|
||||
link.jira_issue_key, jira_account_id, exc_a,
|
||||
)
|
||||
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
db.flush()
|
||||
logger.info(
|
||||
|
||||
@@ -26,7 +26,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
# Import settings from app.config
|
||||
from app.config import settings
|
||||
from app.domain.exceptions import InvalidOperationError
|
||||
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
|
||||
from app.domain.test_entity import TestEntity
|
||||
from app.models.enums import TestState, TeamSide
|
||||
from app.models.evidence import Evidence
|
||||
@@ -263,7 +263,7 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
|
||||
# Assign test = transition_state(
|
||||
test = transition_state(
|
||||
db, test, TestState.blue_evaluating, user,
|
||||
db, test, TestState.red_review, user,
|
||||
# Keyword argument: action_name
|
||||
action_name="submit_red_evidence",
|
||||
)
|
||||
@@ -287,10 +287,52 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
description=f"Red Team execution: {test.name}",
|
||||
)
|
||||
|
||||
# Start Blue Team timer
|
||||
test.blue_started_at = now
|
||||
# Assign test.blue_paused_seconds = 0
|
||||
test.blue_paused_seconds = 0
|
||||
reviewer = select_reviewer(
|
||||
db, role="red_lead",
|
||||
exclude_user_id=user.id if user.role == "red_lead" else None,
|
||||
)
|
||||
test.red_reviewer_assignee = reviewer.id
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
create_notification(
|
||||
db, user_id=reviewer.id, type="review_assigned",
|
||||
title="Test awaiting your review",
|
||||
message=f'Test "{test.name}" is waiting for your red-team review.',
|
||||
entity_type="test", entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "red_review", assignee=reviewer)
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
def approve_red_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
||||
"""Red Lead approves the operator's work — moves red_review to blue_evaluating."""
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.approve_red_review()
|
||||
entity.apply_to(test)
|
||||
test.red_review_by = user.id
|
||||
test.red_review_at = datetime.utcnow()
|
||||
test.red_review_notes = notes
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="approve_red_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
try:
|
||||
notify_test_state_change(db, test, "blue_evaluating")
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
@@ -301,6 +343,39 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
return test
|
||||
|
||||
|
||||
def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
"""Red Lead sends the operator's work back for rework — moves red_review to red_executing."""
|
||||
if not notes or not notes.strip():
|
||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.reopen_red_review()
|
||||
entity.apply_to(test)
|
||||
test.red_review_by = user.id
|
||||
test.red_review_at = datetime.utcnow()
|
||||
test.red_review_notes = notes.strip()
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="reopen_red_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
if test.red_tech_assignee:
|
||||
try:
|
||||
create_notification(
|
||||
db, user_id=test.red_tech_assignee, type="test_reopened",
|
||||
title="Test sent back for rework",
|
||||
message=f'Test "{test.name}" was sent back by your Red Lead: {notes.strip()[:200]}',
|
||||
entity_type="test", entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
||||
"""Mark that a blue tech has picked up this test to start evaluating.
|
||||
|
||||
@@ -368,7 +443,7 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
|
||||
# Assign test = transition_state(
|
||||
test = transition_state(
|
||||
db, test, TestState.in_review, user,
|
||||
db, test, TestState.blue_review, user,
|
||||
# Keyword argument: action_name
|
||||
action_name="submit_blue_evidence",
|
||||
)
|
||||
@@ -392,6 +467,121 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
description=f"Blue Team evaluation: {test.name}",
|
||||
)
|
||||
|
||||
reviewer = select_reviewer(
|
||||
db, role="blue_lead",
|
||||
exclude_user_id=user.id if user.role == "blue_lead" else None,
|
||||
)
|
||||
test.blue_reviewer_assignee = reviewer.id
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
create_notification(
|
||||
db, user_id=reviewer.id, type="review_assigned",
|
||||
title="Test awaiting your review",
|
||||
message=f'Test "{test.name}" is waiting for your blue-team review.',
|
||||
entity_type="test", entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "blue_review", assignee=reviewer)
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
def approve_blue_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
||||
"""Blue Lead approves the operator's work — moves blue_review to in_review."""
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.approve_blue_review()
|
||||
entity.apply_to(test)
|
||||
test.blue_review_by = user.id
|
||||
test.blue_review_at = datetime.utcnow()
|
||||
test.blue_review_notes = notes
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="approve_blue_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
try:
|
||||
notify_test_state_change(db, test, "in_review")
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "in_review")
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
"""Blue Lead sends the operator's work back for rework — moves blue_review to blue_evaluating."""
|
||||
if not notes or not notes.strip():
|
||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.reopen_blue_review()
|
||||
entity.apply_to(test)
|
||||
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
||||
test.blue_review_by = user.id
|
||||
test.blue_review_at = datetime.utcnow()
|
||||
test.blue_review_notes = notes.strip()
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="reopen_blue_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
if test.blue_tech_assignee:
|
||||
try:
|
||||
create_notification(
|
||||
db, user_id=test.blue_tech_assignee, type="test_reopened",
|
||||
title="Test sent back for rework",
|
||||
message=f'Test "{test.name}" was sent back by your Blue Lead: {notes.strip()[:200]}',
|
||||
entity_type="test", entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
def flag_blue_review_gap(db: Session, test: Test, user: User, system_gaps: str, notes: str | None = None) -> Test:
|
||||
"""Blue Lead flags a capability gap — proceeds to in_review anyway (a retry can't fix a missing tool)."""
|
||||
if not system_gaps or not system_gaps.strip():
|
||||
raise InvalidOperationError("system_gaps description is required when flagging a capability gap")
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.flag_blue_review_gap()
|
||||
entity.apply_to(test)
|
||||
test.blue_review_by = user.id
|
||||
test.blue_review_at = datetime.utcnow()
|
||||
test.blue_review_notes = notes
|
||||
test.system_gaps = system_gaps.strip()
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="flag_blue_review_gap",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"system_gaps": system_gaps, "notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
try:
|
||||
notify_test_state_change(db, test, "in_review")
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "in_review")
|
||||
@@ -502,6 +692,52 @@ def resume_timer(db: Session, test: Test, user: User) -> Test:
|
||||
return test
|
||||
|
||||
|
||||
def select_reviewer(
|
||||
db: Session,
|
||||
*,
|
||||
role: str,
|
||||
exclude_user_id: uuid.UUID | None = None,
|
||||
) -> User:
|
||||
"""Pick the least-loaded active user with *role* to review a test.
|
||||
|
||||
Load is measured as the count of tests currently sitting in the
|
||||
matching review state (``red_review`` for role ``red_lead``,
|
||||
``blue_review`` for role ``blue_lead``) with that user set as the
|
||||
reviewer. Ties broken by username for determinism.
|
||||
|
||||
Raises BusinessRuleViolation if no eligible reviewer exists (e.g. the
|
||||
only lead is the person who executed the test, or there are no leads
|
||||
with this role at all).
|
||||
"""
|
||||
review_state = TestState.red_review if role == "red_lead" else TestState.blue_review
|
||||
reviewer_field = Test.red_reviewer_assignee if role == "red_lead" else Test.blue_reviewer_assignee
|
||||
|
||||
candidates_query = db.query(User).filter(User.role == role, User.is_active == True) # noqa: E712
|
||||
if exclude_user_id is not None:
|
||||
candidates_query = candidates_query.filter(User.id != exclude_user_id)
|
||||
candidates = candidates_query.order_by(User.username).all()
|
||||
|
||||
if not candidates:
|
||||
raise BusinessRuleViolation(
|
||||
f"No available {role} to review this test (cannot self-review, "
|
||||
f"and no other {role} is active)"
|
||||
)
|
||||
|
||||
best_user = None
|
||||
best_count = None
|
||||
for candidate in candidates:
|
||||
count = (
|
||||
db.query(Test)
|
||||
.filter(Test.state == review_state, reviewer_field == candidate.id)
|
||||
.count()
|
||||
)
|
||||
if best_count is None or count < best_count:
|
||||
best_user = candidate
|
||||
best_count = count
|
||||
|
||||
return best_user
|
||||
|
||||
|
||||
# Define function _create_phase_worklog
|
||||
def _create_phase_worklog(
|
||||
# Entry: db
|
||||
|
||||
Reference in New Issue
Block a user