From 4ddd7f9ec3ff1f5bee8d4e67ca14d85f452f0ba5 Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 8 Jul 2026 13:39:15 +0200 Subject: [PATCH] feat(jira): sync the full Purple Team workflow status to Jira MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only red_executing (-> "In Progress") ever changed the Jira issue's status; every other Aegis transition (red_review, blue_evaluating, blue_review, in_review, validated, rejected, disputed) only posted a comment, leaving the Jira board stuck on whatever status it started with. Expands push_test_event() to a full TestState -> Jira-status table covering the whole lifecycle (To-Do -> In Progress -> RT Test Review -> Queued Blue Team -> In Progress -> Blue Team Test Review -> Validation -> Done/Rejected/Dispute), matching the Purple Team Jira workflow. Adds two new lifecycle hooks that the generic dispatch can't handle correctly: - push_bt_work_started(): blue_evaluating covers both "queued, unclaimed" and "actively being worked" — needs an explicit push when the blue tech clicks Start Evaluation, or it never leaves "Queued Blue Team". - disputed state push: a conflicting lead vote previously produced zero Jira signal at all. Also fills two real gaps: reopen_red_review and reopen_blue_review never synced anything to Jira before. Their target status differs on purpose — reopen_red_review pushes "In Progress" (Aegis resumes the same operator immediately, no re-claim), while reopen_blue_review pushes "Queued Blue Team" (Aegis clears blue_work_started_at, forcing a fresh "Start Evaluation" click) — verified against the actual field-reset behavior in test_entity.py rather than assumed symmetry between the two teams. --- backend/app/services/jira_service.py | 85 +++++++++++++++++-- backend/app/services/test_workflow_service.py | 24 ++++++ backend/tests/test_jira_service.py | 61 +++++++++++++ backend/tests/test_workflow.py | 73 ++++++++++++++++ 4 files changed, 236 insertions(+), 7 deletions(-) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index faaf746..ec1fd2e 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -355,6 +355,31 @@ _STATE_EMOJI: dict[str, str] = { "in_review": "📋 In Review", "validated": "✅ Validated", "rejected": "❌ Rejected", + "disputed": "⚠️ Disputed", +} + +# TestState -> Jira workflow status. These status names must already exist +# as valid transition targets in the Purple Team project's Jira workflow +# scheme (configured by a Jira admin) — set_issue_status() is a no-op +# (logged warning, non-fatal) if the named status isn't a legal transition +# from the issue's current status. +# +# "blue_evaluating" maps to "Queued Blue Team" here because that's the +# state's *first-entry* meaning (approved by Red Lead, not yet claimed by +# Blue, or re-routed to Blue Team during dispute resolution). When Blue +# Lead sends work back to the *same* operator for rework +# (reopen_blue_review), that's pushed as "In Progress" directly instead of +# through this table — see push_bt_review_reopened(). +_STATE_TO_JIRA_STATUS: dict[str, str] = { + "draft": "To-Do", + "red_executing": "In Progress", + "red_review": "RT Test Review", + "blue_evaluating": "Queued Blue Team", + "blue_review": "Blue Team Test Review", + "in_review": "Validation", + "validated": "Done", + "rejected": "Rejected", + "disputed": "Dispute", } @@ -757,19 +782,26 @@ def push_test_event( comment = _build_state_comment(test, new_state, actor, extra) jira.issue_add_comment(link.jira_issue_key, comment) - # When the operator starts execution: transition to "In Progress" - # and assign the ticket to that operator. - if new_state == "red_executing": + # Transition the Jira issue's status to match the new Aegis state, + # per _STATE_TO_JIRA_STATUS. Covers the full Purple Team workflow: + # To-Do -> In Progress -> RT Test Review -> Queued Blue Team -> + # In Progress -> Blue Team Test Review -> Validation -> Done / + # Rejected / Dispute. + target_status = _STATE_TO_JIRA_STATUS.get(new_state) + if target_status: try: - jira.set_issue_status(link.jira_issue_key, "In Progress") + jira.set_issue_status(link.jira_issue_key, target_status) logger.info( - "Transitioned Jira ticket %s to In Progress", link.jira_issue_key + "Transitioned Jira ticket %s to %s", link.jira_issue_key, target_status ) except Exception as exc_t: logger.warning( - "Could not transition %s to In Progress: %s", - link.jira_issue_key, exc_t, + "Could not transition %s to %s: %s", + link.jira_issue_key, target_status, exc_t, ) + + # When the operator starts execution: assign the ticket to that operator. + if new_state == "red_executing": jira_account_id = getattr(actor, "jira_account_id", None) if jira_account_id: try: @@ -926,6 +958,45 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True) +def push_bt_work_started(db: Session, test, actor) -> None: + """Transition the Jira issue to "In Progress" when a blue tech picks up + a queued test to start evaluating. + + The test's TestState stays "blue_evaluating" throughout (queued AND + actively worked are the same Aegis state), so this can't be driven by + the generic push_test_event()/_STATE_TO_JIRA_STATUS dispatch — that + would incorrectly re-push "Queued Blue Team". Non-fatal. + """ + if not has_admin_jira_configured(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_admin_jira_client(db) + ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + comment = f"h3. ▶ Blue Team Started\n\n*Picked up by:* {actor.username}\n*At:* {ts}" + try: + jira.set_issue_status(link.jira_issue_key, "In Progress") + except Exception as exc_t: + logger.warning("Could not transition %s to In Progress: %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 blue-team-started event to Jira %s", link.jira_issue_key) + except Exception as exc: + logger.warning("Failed to push blue-team-started event for test %s: %s", test.id, exc, exc_info=True) + + # --------------------------------------------------------------------------- # RT/BT date + result custom-field sync # --------------------------------------------------------------------------- diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index defc13e..4751a55 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -408,6 +408,12 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test: 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_executing", extra={"notes": notes.strip()}) + except Exception as e: + logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) + return test @@ -447,6 +453,12 @@ def start_blue_work(db: Session, test: Test, user: User) -> Test: except Exception as e: logger.warning("Jira BT start date push failed for test %s: %s", test.id, e, exc_info=True) + try: + from app.services.jira_service import push_bt_work_started + push_bt_work_started(db, test, user) + except Exception as e: + logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) + return test @@ -601,6 +613,12 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test: 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_evaluating", extra={"notes": notes.strip()}) + except Exception as e: + logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) + return test @@ -1122,6 +1140,12 @@ def _dispatch_dual_validation_effects( logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) elif event.name == "dual_validation_disputed": + if actor: + try: + from app.services.jira_service import push_test_event + push_test_event(db, test, actor, "disputed") + except Exception as e: + logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) # Notify the lead who APPROVED asking them to review the rejection _notify_validation_conflict(db, test, actor) # Notify managers too, in case the dispute stalls and needs escalation diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index 6d1db3b..a50a5be 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -56,6 +56,15 @@ def _make_test(**overrides): t.execution_end_time = None t.detection_time = None t.containment_time = None + # _build_state_comment() appends these raw (not via an f-string) when + # truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines). + t.red_summary = None + t.blue_summary = None + t.remediation_steps = None + t.red_validation_status = None + t.blue_validation_status = None + t.red_validation_notes = None + t.blue_validation_notes = None for k, v in overrides.items(): setattr(t, k, v) return t @@ -107,6 +116,58 @@ def test_push_hold_event_sets_blocked(mock_get_client, mock_configured, db): mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "Blocked") +@pytest.mark.parametrize( + "new_state,expected_status", + [ + ("draft", "To-Do"), + ("red_executing", "In Progress"), + ("red_review", "RT Test Review"), + ("blue_evaluating", "Queued Blue Team"), + ("blue_review", "Blue Team Test Review"), + ("in_review", "Validation"), + ("validated", "Done"), + ("rejected", "Rejected"), + ("disputed", "Dispute"), + ], +) +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_test_event_maps_every_state_to_jira_status( + mock_get_client, mock_configured, db, new_state, expected_status +): + """Every TestState the Purple Team Jira workflow needs to reflect must + have a corresponding status transition — not just red_executing.""" + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test() + link = _make_link(test.id) + db.add(link) + db.commit() + + jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), new_state) + + mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, expected_status) + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_bt_work_started_sets_in_progress(mock_get_client, mock_configured, db): + """blue_evaluating covers both 'queued, unclaimed' and 'actively being + worked' — the state alone can't distinguish them, so start_blue_work + must explicitly push In Progress rather than relying on the generic + state->status dispatch (which would incorrectly re-push Queued Blue Team).""" + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test() + link = _make_link(test.id) + db.add(link) + db.commit() + + jira_service.push_bt_work_started(db, test, MagicMock(username="bob")) + + mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress") + + @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.get_admin_jira_client") def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db): diff --git a/backend/tests/test_workflow.py b/backend/tests/test_workflow.py index 50d6b69..5d573d2 100644 --- a/backend/tests/test_workflow.py +++ b/backend/tests/test_workflow.py @@ -690,6 +690,79 @@ class TestReviewDecisions: assert result.state == TestState.in_review assert result.system_gaps == "Missing EDR agent on host X" + @patch("app.services.jira_service.push_test_event") + @patch("app.services.test_workflow_service.log_action") + def test_reopen_red_review_pushes_in_progress_to_jira(self, mock_log, mock_push): + """Sent back for RT rework resumes the same operator immediately — + must push 'In Progress', not require a fresh queue/re-claim.""" + test = _make_test(TestState.red_review, red_tech_assignee=uuid.uuid4()) + reviewer = _make_user("red_lead") + db = _make_db() + + from app.services.test_workflow_service import reopen_red_review + reopen_red_review(db, test, reviewer, notes="add more detail") + + mock_push.assert_called_once() + args, kwargs = mock_push.call_args + assert args[3] == "red_executing" + + @patch("app.services.jira_service.push_test_event") + @patch("app.services.test_workflow_service.log_action") + def test_reopen_blue_review_pushes_blue_evaluating_to_jira(self, mock_log, mock_push): + """Sent back for BT rework clears blue_work_started_at (the operator + must re-claim via 'Start Evaluation') — must push 'Queued Blue Team', + the same status a first-time entry gets.""" + test = _make_test(TestState.blue_review, blue_tech_assignee=uuid.uuid4()) + reviewer = _make_user("blue_lead") + db = _make_db() + + from app.services.test_workflow_service import reopen_blue_review + reopen_blue_review(db, test, reviewer, notes="redo it") + + mock_push.assert_called_once() + args, kwargs = mock_push.call_args + assert args[3] == "blue_evaluating" + + +class TestStartBlueWork: + @patch("app.services.jira_service.push_bt_work_started") + @patch("app.services.jira_service.push_bt_started") + @patch("app.services.test_workflow_service.log_action") + def test_start_blue_work_pushes_in_progress(self, mock_log, mock_push_started, mock_push_work_started): + test = _make_test(TestState.blue_evaluating) + test.blue_work_started_at = None + blue_tech = _make_user("blue_tech") + db = _make_db() + + from app.services.test_workflow_service import start_blue_work + result = start_blue_work(db, test, blue_tech) + + assert result.blue_work_started_at is not None + mock_push_work_started.assert_called_once() + + +class TestDisputedJiraSync: + @patch("app.services.jira_service.push_test_event") + @patch("app.services.test_workflow_service.log_action") + def test_dual_validation_disputed_pushes_dispute_status(self, mock_log, mock_push): + """A conflicting vote (one lead approved, the other rejected) must + surface as a distinct 'Dispute' status in Jira — previously this + transition posted no Jira signal at all.""" + test = _make_test( + TestState.in_review, + red_validation_status="approved", + ) + blue_lead = _make_user("blue_lead") + db = _make_db() + + from app.services.test_workflow_service import validate_as_blue_lead + result = validate_as_blue_lead(db, test, blue_lead, "rejected", notes="Not detected") + + assert result.state == TestState.disputed + mock_push.assert_called_once() + args, kwargs = mock_push.call_args + assert args[3] == "disputed" + # =========================================================================== # 12c. resolve_dispute — flip approver's vote to reject, route to a team