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
|
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 "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user