diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 32624fb..f032fa3 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -211,6 +211,7 @@ class JiraConfigOut(BaseModel): url: str project_key: str parent_ticket: str + parent_ticket_standalone: str # parent for tests not in a campaign # Credentials are never returned @@ -219,6 +220,7 @@ class JiraConfigUpdate(BaseModel): url: Optional[str] = None project_key: Optional[str] = None parent_ticket: Optional[str] = None + parent_ticket_standalone: Optional[str] = None _JIRA_KEYS = { @@ -226,6 +228,7 @@ _JIRA_KEYS = { "url": "jira.url", "project_key": "jira.project_key", "parent_ticket": "jira.parent_ticket", + "parent_ticket_standalone": "jira.parent_ticket_standalone", } @@ -238,13 +241,17 @@ def get_jira_config( **Requires** the ``admin`` role. Credentials are never returned. """ - from app.services.jira_service import get_jira_url, get_jira_project_key, is_jira_enabled, get_jira_parent_ticket + from app.services.jira_service import ( + get_jira_url, get_jira_project_key, is_jira_enabled, + get_jira_parent_ticket, get_jira_parent_ticket_standalone, + ) return JiraConfigOut( enabled=is_jira_enabled(db), url=get_jira_url(db) or "", project_key=get_jira_project_key(db) or "", parent_ticket=get_jira_parent_ticket(db) or "", + parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "", ) @@ -259,7 +266,8 @@ def update_jira_config( **Requires** the ``admin`` role. Only provided fields are updated. """ from app.services.jira_service import ( - upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled, get_jira_parent_ticket, + upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled, + get_jira_parent_ticket, get_jira_parent_ticket_standalone, ) update_data = payload.model_dump(exclude_unset=True) @@ -274,6 +282,7 @@ def update_jira_config( url=get_jira_url(db) or "", project_key=get_jira_project_key(db) or "", parent_ticket=get_jira_parent_ticket(db) or "", + parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "", ) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 0f67d1b..7e0ec16 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -88,10 +88,21 @@ def is_jira_enabled(db: Session) -> bool: def get_jira_parent_ticket(db: Session) -> Optional[str]: - """Return the configured parent ticket key, or None if not set.""" + """Return the configured parent ticket key for campaigns, or None if not set.""" return _read_system_config(db, "jira.parent_ticket") or None +def get_jira_parent_ticket_standalone(db: Session) -> Optional[str]: + """Return the parent ticket for standalone tests (not in a campaign). + + Falls back to get_jira_parent_ticket() if not explicitly configured. + """ + return ( + _read_system_config(db, "jira.parent_ticket_standalone") + or get_jira_parent_ticket(db) + ) + + def upsert_jira_config(db: Session, key: str, value: str) -> None: """Persist a Jira config key-value pair.""" from app.models.system_config import SystemConfig @@ -482,9 +493,10 @@ def auto_create_test_issue( "labels": ["aegis", "security-test", mitre_id.replace(".", "-")], } - # Use campaign ticket as parent when provided, otherwise fall back to - # the system-configured parent (e.g. OFS-9107) - parent = parent_ticket_override or get_jira_parent_ticket(db) + # Use campaign ticket as parent when provided; otherwise use the + # standalone-tests parent (e.g. OFS-20798), falling back to the + # general parent ticket if the standalone one is not configured. + parent = parent_ticket_override or get_jira_parent_ticket_standalone(db) if parent: fields["parent"] = {"key": parent} @@ -548,6 +560,34 @@ def push_test_event( jira = get_user_jira_client(actor, db) 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": + try: + jira.set_issue_status(link.jira_issue_key, "In Progress") + logger.info( + "Transitioned Jira ticket %s to In Progress", link.jira_issue_key + ) + except Exception as exc_t: + logger.warning( + "Could not transition %s to In Progress: %s", + link.jira_issue_key, exc_t, + ) + jira_account_id = getattr(actor, "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 account %s", + link.jira_issue_key, jira_account_id, + ) + except Exception as exc_a: + logger.warning( + "Could not assign %s to %s: %s", + link.jira_issue_key, jira_account_id, exc_a, + ) + link.last_synced_at = datetime.utcnow() db.flush() logger.info( diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 9ec2886..aa5d18d 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -162,6 +162,7 @@ export interface JiraConfigOut { url: string; project_key: string; parent_ticket: string; + parent_ticket_standalone: string; } export interface JiraConfigUpdate { @@ -169,6 +170,7 @@ export interface JiraConfigUpdate { url?: string; project_key?: string; parent_ticket?: string; + parent_ticket_standalone?: string; } export async function getJiraConfig(): Promise { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 1de9fce..ac91018 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1218,16 +1218,30 @@ function JiraConfigSection() {
- + setForm((prev) => ({ ...prev, parent_ticket: e.target.value }))} - placeholder="SEC-100" + placeholder="OFS-9107" className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" />

- If set, all test tickets will be created as subtasks of this issue + Campaign tickets are nested under this issue +

+
+ +
+ + setForm((prev) => ({ ...prev, parent_ticket_standalone: e.target.value }))} + placeholder="OFS-20798" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +

+ Standalone tests (not in a campaign) are nested under this issue. Falls back to Campaign Parent if not set.

diff --git a/frontend/src/pages/TestsPage.tsx b/frontend/src/pages/TestsPage.tsx index 25209a3..e1efa55 100644 --- a/frontend/src/pages/TestsPage.tsx +++ b/frontend/src/pages/TestsPage.tsx @@ -14,6 +14,9 @@ import { Play, Shield, Search, + ChevronUp, + ChevronDown, + ChevronsUpDown, } from "lucide-react"; import { getTests, type TestListFilters } from "../api/tests"; import type { Test, TestState } from "../types/models"; @@ -83,6 +86,20 @@ export default function TestsPage() { const [searchText, setSearchText] = useState(""); const [showMyTasks, setShowMyTasks] = useState(false); + // ── Sort state ──────────────────────────────────────────────────── + type SortKey = "name" | "technique" | "state" | "team" | "platform" | "created_at" | "updated_at"; + const [sortKey, setSortKey] = useState("created_at"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + }; + // Build API filters const filters = useMemo(() => { const f: TestListFilters = { limit: 200 }; @@ -153,8 +170,46 @@ export default function TestsPage() { ); } + // Sort + filtered = [...filtered].sort((a, b) => { + let av: string = ""; + let bv: string = ""; + switch (sortKey) { + case "name": + av = a.name.toLowerCase(); + bv = b.name.toLowerCase(); + break; + case "technique": + av = (a.technique_mitre_id || "").toLowerCase(); + bv = (b.technique_mitre_id || "").toLowerCase(); + break; + case "state": + av = a.state; + bv = b.state; + break; + case "team": + av = currentTeamForState(a.state); + bv = currentTeamForState(b.state); + break; + case "platform": + av = (a.platform || "").toLowerCase(); + bv = (b.platform || "").toLowerCase(); + break; + case "created_at": + av = a.created_at || ""; + bv = b.created_at || ""; + break; + case "updated_at": + av = a.updated_at || ""; + bv = b.updated_at || ""; + break; + } + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sortDir === "asc" ? cmp : -cmp; + }); + return filtered; - }, [allTests, searchText, showMyTasks, user, stateFilter]); + }, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]); // ── State counters ──────────────────────────────────────────────── // Count from allTests (before client search filter) to show accurate pipeline @@ -191,9 +246,11 @@ export default function TestsPage() { const totalTests = allTestsUnfiltered?.length || 0; // ── Formatting helpers ───────────────────────────────────────────── - const formatDate = (dateStr: string | null) => { + const formatDate = (dateStr: string | null | undefined) => { if (!dateStr) return "-"; - return new Date(dateStr).toLocaleDateString("en-US", { + // Backend returns naive UTC; append Z so JS treats it as UTC + const utc = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z"; + return new Date(utc).toLocaleDateString("es-ES", { year: "numeric", month: "short", day: "numeric", @@ -412,12 +469,36 @@ export default function TestsPage() { - - - - - - + {( + [ + { key: "name", label: "Name", cls: "pr-4" }, + { key: "technique", label: "Technique", cls: "px-4" }, + { key: "state", label: "State", cls: "px-4" }, + { key: "team", label: "Current Team", cls: "px-4" }, + { key: "platform", label: "Platform", cls: "px-4" }, + { key: "created_at", label: "Created", cls: "px-4" }, + { key: "updated_at", label: "Updated", cls: "px-4" }, + ] as { key: SortKey; label: string; cls: string }[] + ).map(({ key, label, cls }) => ( + + ))} @@ -460,9 +541,12 @@ export default function TestsPage() { - +
NameTechniqueStateCurrent TeamPlatformUpdated handleSort(key)} + > + + {label} + {sortKey === key ? ( + sortDir === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} + + Action
{test.platform || "-"} + {formatDate(test.created_at)} + {formatDate(test.updated_at)} +