feat(jira+tests): 5 improvements from review
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

1. Jira status → In Progress on Start Execution
   - push_test_event calls set_issue_status("In Progress") when
     new_state == "red_executing" (non-fatal, separate try/except)

2. Jira assignee set on Start Execution
   - assign_issue() called with actor.jira_account_id when operator
     clicks Start (non-fatal)

3. Standalone tests parent ticket (OFS-20798)
   - New jira.parent_ticket_standalone config key
   - get_jira_parent_ticket_standalone() falls back to parent_ticket
   - auto_create_test_issue uses standalone parent for non-campaign tests
   - Exposed in /system/jira-config GET+PATCH and SettingsPage UI

4. Tests table: Created + Updated columns
   - Add Created column (created_at), fix Updated to show updated_at
   - Both use UTC-aware date parsing (append Z if no tz suffix)
   - updated_at added to Test TypeScript interface

5. Sortable columns in tests table
   - All 7 columns sortable: Name, Technique, State, Current Team,
     Platform, Created, Updated
   - Click to sort asc, click again to reverse; ChevronUp/Down indicator
   - Default sort: Created desc (newest first)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-27 13:07:46 +02:00
parent 43c8b241dc
commit eeee17d260
6 changed files with 169 additions and 19 deletions

View File

@@ -211,6 +211,7 @@ class JiraConfigOut(BaseModel):
url: str url: str
project_key: str project_key: str
parent_ticket: str parent_ticket: str
parent_ticket_standalone: str # parent for tests not in a campaign
# Credentials are never returned # Credentials are never returned
@@ -219,6 +220,7 @@ class JiraConfigUpdate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
project_key: Optional[str] = None project_key: Optional[str] = None
parent_ticket: Optional[str] = None parent_ticket: Optional[str] = None
parent_ticket_standalone: Optional[str] = None
_JIRA_KEYS = { _JIRA_KEYS = {
@@ -226,6 +228,7 @@ _JIRA_KEYS = {
"url": "jira.url", "url": "jira.url",
"project_key": "jira.project_key", "project_key": "jira.project_key",
"parent_ticket": "jira.parent_ticket", "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. **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( return JiraConfigOut(
enabled=is_jira_enabled(db), enabled=is_jira_enabled(db),
url=get_jira_url(db) or "", url=get_jira_url(db) or "",
project_key=get_jira_project_key(db) or "", project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(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. **Requires** the ``admin`` role. Only provided fields are updated.
""" """
from app.services.jira_service import ( 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) update_data = payload.model_dump(exclude_unset=True)
@@ -274,6 +282,7 @@ def update_jira_config(
url=get_jira_url(db) or "", url=get_jira_url(db) or "",
project_key=get_jira_project_key(db) or "", project_key=get_jira_project_key(db) or "",
parent_ticket=get_jira_parent_ticket(db) or "", parent_ticket=get_jira_parent_ticket(db) or "",
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
) )

View File

@@ -88,10 +88,21 @@ def is_jira_enabled(db: Session) -> bool:
def get_jira_parent_ticket(db: Session) -> Optional[str]: 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 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: def upsert_jira_config(db: Session, key: str, value: str) -> None:
"""Persist a Jira config key-value pair.""" """Persist a Jira config key-value pair."""
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
@@ -482,9 +493,10 @@ def auto_create_test_issue(
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")], "labels": ["aegis", "security-test", mitre_id.replace(".", "-")],
} }
# Use campaign ticket as parent when provided, otherwise fall back to # Use campaign ticket as parent when provided; otherwise use the
# the system-configured parent (e.g. OFS-9107) # standalone-tests parent (e.g. OFS-20798), falling back to the
parent = parent_ticket_override or get_jira_parent_ticket(db) # general parent ticket if the standalone one is not configured.
parent = parent_ticket_override or get_jira_parent_ticket_standalone(db)
if parent: if parent:
fields["parent"] = {"key": parent} fields["parent"] = {"key": parent}
@@ -548,6 +560,34 @@ def push_test_event(
jira = get_user_jira_client(actor, db) jira = get_user_jira_client(actor, db)
comment = _build_state_comment(test, new_state, actor, extra) comment = _build_state_comment(test, new_state, actor, extra)
jira.issue_add_comment(link.jira_issue_key, comment) 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() link.last_synced_at = datetime.utcnow()
db.flush() db.flush()
logger.info( logger.info(

View File

@@ -162,6 +162,7 @@ export interface JiraConfigOut {
url: string; url: string;
project_key: string; project_key: string;
parent_ticket: string; parent_ticket: string;
parent_ticket_standalone: string;
} }
export interface JiraConfigUpdate { export interface JiraConfigUpdate {
@@ -169,6 +170,7 @@ export interface JiraConfigUpdate {
url?: string; url?: string;
project_key?: string; project_key?: string;
parent_ticket?: string; parent_ticket?: string;
parent_ticket_standalone?: string;
} }
export async function getJiraConfig(): Promise<JiraConfigOut> { export async function getJiraConfig(): Promise<JiraConfigOut> {

View File

@@ -1218,16 +1218,30 @@ function JiraConfigSection() {
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Parent Ticket (optional)</label> <label className="mb-1 block text-xs font-medium text-cyan-400">Campaign Parent Ticket (optional)</label>
<input <input
type="text" type="text"
value={String(form.parent_ticket !== undefined ? form.parent_ticket : (cfg?.parent_ticket ?? ""))} value={String(form.parent_ticket !== undefined ? form.parent_ticket : (cfg?.parent_ticket ?? ""))}
onChange={(e) => setForm((prev) => ({ ...prev, parent_ticket: e.target.value }))} onChange={(e) => 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" 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"
/> />
<p className="mt-1 text-xs text-gray-600"> <p className="mt-1 text-xs text-gray-600">
If set, all test tickets will be created as subtasks of this issue Campaign tickets are nested under this issue
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-cyan-400">Standalone Tests Parent Ticket (optional)</label>
<input
type="text"
value={String(form.parent_ticket_standalone !== undefined ? form.parent_ticket_standalone : (cfg?.parent_ticket_standalone ?? ""))}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-gray-600">
Standalone tests (not in a campaign) are nested under this issue. Falls back to Campaign Parent if not set.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,9 @@ import {
Play, Play,
Shield, Shield,
Search, Search,
ChevronUp,
ChevronDown,
ChevronsUpDown,
} from "lucide-react"; } from "lucide-react";
import { getTests, type TestListFilters } from "../api/tests"; import { getTests, type TestListFilters } from "../api/tests";
import type { Test, TestState } from "../types/models"; import type { Test, TestState } from "../types/models";
@@ -83,6 +86,20 @@ export default function TestsPage() {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [showMyTasks, setShowMyTasks] = useState(false); const [showMyTasks, setShowMyTasks] = useState(false);
// ── Sort state ────────────────────────────────────────────────────
type SortKey = "name" | "technique" | "state" | "team" | "platform" | "created_at" | "updated_at";
const [sortKey, setSortKey] = useState<SortKey>("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 // Build API filters
const filters = useMemo<TestListFilters>(() => { const filters = useMemo<TestListFilters>(() => {
const f: TestListFilters = { limit: 200 }; 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; return filtered;
}, [allTests, searchText, showMyTasks, user, stateFilter]); }, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]);
// ── State counters ──────────────────────────────────────────────── // ── State counters ────────────────────────────────────────────────
// Count from allTests (before client search filter) to show accurate pipeline // Count from allTests (before client search filter) to show accurate pipeline
@@ -191,9 +246,11 @@ export default function TestsPage() {
const totalTests = allTestsUnfiltered?.length || 0; const totalTests = allTestsUnfiltered?.length || 0;
// ── Formatting helpers ───────────────────────────────────────────── // ── Formatting helpers ─────────────────────────────────────────────
const formatDate = (dateStr: string | null) => { const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return "-"; 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", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -412,12 +469,36 @@ export default function TestsPage() {
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-800"> <tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Name</th> {(
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th> [
<th className="pb-3 px-4 font-medium text-gray-400">State</th> { key: "name", label: "Name", cls: "pr-4" },
<th className="pb-3 px-4 font-medium text-gray-400">Current Team</th> { key: "technique", label: "Technique", cls: "px-4" },
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th> { key: "state", label: "State", cls: "px-4" },
<th className="pb-3 px-4 font-medium text-gray-400">Updated</th> { 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 }) => (
<th
key={key}
className={`pb-3 ${cls} font-medium text-gray-400 cursor-pointer select-none hover:text-white transition-colors`}
onClick={() => handleSort(key)}
>
<span className="inline-flex items-center gap-1">
{label}
{sortKey === key ? (
sortDir === "asc" ? (
<ChevronUp className="h-3.5 w-3.5 text-cyan-400" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-cyan-400" />
)
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-30" />
)}
</span>
</th>
))}
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th> <th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
</tr> </tr>
</thead> </thead>
@@ -460,9 +541,12 @@ export default function TestsPage() {
<td className="py-3 px-4 text-gray-400 text-xs"> <td className="py-3 px-4 text-gray-400 text-xs">
{test.platform || "-"} {test.platform || "-"}
</td> </td>
<td className="py-3 px-4 text-gray-400 text-xs"> <td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
{formatDate(test.created_at)} {formatDate(test.created_at)}
</td> </td>
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
{formatDate(test.updated_at)}
</td>
<td className="py-3 pl-4"> <td className="py-3 pl-4">
<button <button
onClick={(e) => { onClick={(e) => {

View File

@@ -70,6 +70,7 @@ export interface Test {
result: TestResult | null; result: TestResult | null;
state: TestState; state: TestState;
created_at: string; created_at: string;
updated_at: string | null;
// Red Team fields // Red Team fields
red_summary: string | null; red_summary: string | null;