feat(review): review queue for leads + manual reviewer reassignment
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

Leads get the same two-queue view operators already have: 'Available
to Review' (pending reviews currently assigned to a peer lead) and
'My Assigned Reviews' (assigned to me), replacing the old single
'My Reviews' toggle. Since the load-balanced auto-assignment always
picks a reviewer immediately, 'available' means peer-assigned reviews
a lead could pick up if the assignee can't get to them, not unclaimed
ones (there aren't any).

POST /tests/{id}/assign now also accepts red_reviewer_assignee /
blue_reviewer_assignee, validated against the matching lead role and
synced to Jira the same way operator assignment already is. The
AssigneeControl UI gained a 'reviewer' kind (leads-only picker) shown
on the test detail header while a test sits in red_review/blue_review
— this also fixes the reviewer assignment being invisible in Aegis
even though it was already being pushed to Jira correctly.
This commit is contained in:
kitos
2026-07-10 16:19:14 +02:00
parent be2a3fdced
commit 0fc2427f71
8 changed files with 250 additions and 72 deletions
+75 -45
View File
@@ -149,8 +149,8 @@ export default function TestsPage() {
const [platformFilter, setPlatformFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [showMyTasks, setShowMyTasks] = useState(false);
const [showMyReviews, setShowMyReviews] = useState(false);
const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead";
const reviewQueueActive = isReviewLead && !showMyTasks;
// ── Sort state ────────────────────────────────────────────────────
const [sortKey, setSortKey] = useState<SortKey>("created_at");
@@ -169,13 +169,7 @@ export default function TestsPage() {
const filters = useMemo<TestListFilters>(() => {
const f: TestListFilters = { limit: 200 };
if (showMyReviews && user && isReviewLead) {
// "My reviews" — tests assigned to ME at the red_review/blue_review
// gate. Distinct from "My Tasks" below, which covers the later
// in_review manager-validation stage regardless of assignment.
f.state = user.role === "red_lead" ? "red_review" : "blue_review";
f.reviewer_id = user.id;
} else if (showMyTasks && user) {
if (showMyTasks && user) {
switch (user.role) {
case "red_tech":
f.created_by = user.id;
@@ -200,7 +194,7 @@ export default function TestsPage() {
if (platformFilter) f.platform = platformFilter;
return f;
}, [stateFilter, platformFilter, showMyTasks, showMyReviews, isReviewLead, user]);
}, [stateFilter, platformFilter, showMyTasks, user]);
const {
data: allTests,
@@ -361,6 +355,39 @@ export default function TestsPage() {
return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests };
}, [techRole, user, allTests, searchText, platformFilter]);
// ── Two-queue split for lead review gate (red_review / blue_review) ─
// The auto load-balancer always assigns a reviewer immediately, so
// "available" here means "assigned to another lead" — visible so a
// lead can pick up a peer's review if they can't get to it.
const { availableReviews, myAssignedReviews } = useMemo(() => {
if (!isReviewLead || !user || !allTests || showMyTasks) {
return { availableReviews: [] as typeof tests, myAssignedReviews: [] as typeof tests };
}
let searchFiltered = allTests;
if (searchText.trim()) {
const q = searchText.toLowerCase();
searchFiltered = searchFiltered.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) ||
(t.technique_name && t.technique_name.toLowerCase().includes(q))
);
}
if (platformFilter) {
searchFiltered = searchFiltered.filter((t) =>
t.platform?.toLowerCase().includes(platformFilter.toLowerCase())
);
}
const reviewState = user.role === "red_lead" ? "red_review" : "blue_review";
const reviewerField = user.role === "red_lead" ? "red_reviewer_assignee" : "blue_reviewer_assignee";
const inState = searchFiltered.filter((t) => t.state === reviewState);
const available = inState.filter((t) => t[reviewerField] !== user.id);
const mine = inState.filter((t) => t[reviewerField] === user.id);
return { availableReviews: available, myAssignedReviews: mine };
}, [isReviewLead, user, allTests, searchText, platformFilter, showMyTasks]);
// ── Formatting helpers ─────────────────────────────────────────────
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return "-";
@@ -500,13 +527,14 @@ export default function TestsPage() {
{/* ── Filters Bar ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-center gap-3">
{/* My tasks toggle — hidden for tech roles (they have the two-queue view below) */}
{/* My tasks toggle — hidden for tech roles (two-queue view below);
for leads this switches AWAY from the review two-queue view
to the in_review dual-validation stage, a distinct responsibility. */}
{user?.role !== "admin" && user?.role !== "viewer" && !techRole && (
<button
onClick={() => {
setShowMyTasks(!showMyTasks);
if (!showMyTasks) {
setShowMyReviews(false);
setStateFilter("");
}
}}
@@ -521,29 +549,6 @@ export default function TestsPage() {
</button>
)}
{/* My reviews toggle — red_lead/blue_lead only, the red_review/blue_review
lead-review gate assignment queue (distinct from "My Tasks" above,
which covers the later in_review manager-validation stage). */}
{isReviewLead && (
<button
onClick={() => {
setShowMyReviews(!showMyReviews);
if (!showMyReviews) {
setShowMyTasks(false);
setStateFilter("");
}
}}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
showMyReviews
? "border-cyan-500/50 bg-cyan-500/20 text-cyan-400"
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
}`}
>
<Eye className="h-4 w-4" />
My Reviews
</button>
)}
{/* State filter */}
<div className="flex items-center gap-1.5">
<Filter className="h-4 w-4 text-gray-500" />
@@ -590,14 +595,13 @@ export default function TestsPage() {
</div>
{/* Clear filters */}
{(stateFilter || platformFilter || searchText || showMyTasks || showMyReviews) && (
{(stateFilter || platformFilter || searchText || showMyTasks) && (
<button
onClick={() => {
setStateFilter("");
setPlatformFilter("");
setSearchText("");
setShowMyTasks(false);
setShowMyReviews(false);
}}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
@@ -607,14 +611,9 @@ export default function TestsPage() {
</div>
{/* Active filter summary */}
{(stateFilter || showMyTasks || showMyReviews) && (
{(stateFilter || showMyTasks) && (
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
<span>Showing:</span>
{showMyReviews && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
My Reviews
</span>
)}
{showMyTasks && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
{myTasksLabel}
@@ -664,16 +663,47 @@ export default function TestsPage() {
<TestTable tests={myAssignedTests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No tests currently assigned to you." />
</div>
</div>
) : reviewQueueActive ? (
/* Two-section review queue for red_lead/blue_lead */
<div className="space-y-6">
{/* Available to Review */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white">Available to Review</h2>
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-xs font-medium text-cyan-400">
{availableReviews.length}
</span>
</div>
<span className="text-xs text-gray-500">Assigned to other leads pick one up if needed</span>
</div>
<TestTable tests={availableReviews} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No other pending reviews right now." />
</div>
{/* My Assigned Reviews */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white">My Assigned Reviews</h2>
<span className="rounded-full border border-orange-500/30 bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-400">
{myAssignedReviews.length}
</span>
</div>
<span className="text-xs text-gray-500">Reviews assigned to you</span>
</div>
<TestTable tests={myAssignedReviews} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No reviews currently assigned to you." />
</div>
</div>
) : (
/* Normal single-table view for leads, admin, viewer */
/* Normal single-table view for admin, viewer, and leads viewing "My Tasks" */
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
{showMyReviews ? "My Reviews" : showMyTasks ? myTasksLabel : "All Tests"}
{showMyTasks ? myTasksLabel : "All Tests"}
</h2>
<span className="text-sm text-gray-400">{tests.length} tests</span>
</div>
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyReviews ? "No tests currently assigned to you for review." : showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
</div>
)}
</div>