feat(jira+tests): 5 improvements from review
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. 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:
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<JiraConfigOut> {
|
||||
|
||||
@@ -1218,16 +1218,30 @@ function JiraConfigSection() {
|
||||
</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
|
||||
type="text"
|
||||
value={String(form.parent_ticket !== undefined ? form.parent_ticket : (cfg?.parent_ticket ?? ""))}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<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
|
||||
const filters = useMemo<TestListFilters>(() => {
|
||||
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() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<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>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Current Team</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Updated</th>
|
||||
{(
|
||||
[
|
||||
{ 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 }) => (
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -460,9 +541,12 @@ export default function TestsPage() {
|
||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||
{test.platform || "-"}
|
||||
</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)}
|
||||
</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">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Test {
|
||||
result: TestResult | null;
|
||||
state: TestState;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// Red Team fields
|
||||
red_summary: string | null;
|
||||
|
||||
Reference in New Issue
Block a user