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
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:
@@ -179,10 +179,15 @@ export async function updateTestClassification(
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Assign red_tech/blue_tech operators to a test (leads + managers only). */
|
||||
/** Assign operators or hand off a review to a peer lead (leads + managers only). */
|
||||
export async function assignTestOperators(
|
||||
testId: string,
|
||||
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
|
||||
payload: {
|
||||
red_tech_assignee?: string | null;
|
||||
blue_tech_assignee?: string | null;
|
||||
red_reviewer_assignee?: string | null;
|
||||
blue_reviewer_assignee?: string | null;
|
||||
},
|
||||
): Promise<Test> {
|
||||
const { data } = await client.post<Test>(`/tests/${testId}/assign`, payload);
|
||||
return data;
|
||||
|
||||
@@ -11,6 +11,10 @@ interface Props {
|
||||
onAssign: (userId: string | null) => void;
|
||||
/** "sm" = compact pill (default), "lg" = full-size button matching the action bar. */
|
||||
size?: "sm" | "lg";
|
||||
/** "operator" (default) = red_tech_assignee/blue_tech_assignee, picking from
|
||||
* techs+leads. "reviewer" = red_reviewer_assignee/blue_reviewer_assignee,
|
||||
* picking from leads only — for handing a review off to a peer lead. */
|
||||
kind?: "operator" | "reviewer";
|
||||
}
|
||||
|
||||
const SIDE_STYLE = {
|
||||
@@ -18,20 +22,31 @@ const SIDE_STYLE = {
|
||||
blue: "border-indigo-500/40 bg-indigo-900/20 text-indigo-400 hover:bg-indigo-900/40",
|
||||
};
|
||||
|
||||
const SIDE_ROLES: Record<"red" | "blue", string[]> = {
|
||||
red: ["red_tech", "red_lead"],
|
||||
blue: ["blue_tech", "blue_lead"],
|
||||
const ELIGIBLE_ROLES: Record<"operator" | "reviewer", Record<"red" | "blue", string[]>> = {
|
||||
operator: {
|
||||
red: ["red_tech", "red_lead"],
|
||||
blue: ["blue_tech", "blue_lead"],
|
||||
},
|
||||
reviewer: {
|
||||
red: ["red_lead"],
|
||||
blue: ["blue_lead"],
|
||||
},
|
||||
};
|
||||
|
||||
/** Lead/manager picker for the red_tech_assignee / blue_tech_assignee fields. */
|
||||
const LABEL_PREFIX: Record<"operator" | "reviewer", Record<"red" | "blue", string>> = {
|
||||
operator: { red: "RT", blue: "BT" },
|
||||
reviewer: { red: "Reviewer", blue: "Reviewer" },
|
||||
};
|
||||
|
||||
/** Lead/manager picker for operator assignment or reviewer hand-off. */
|
||||
export default function AssigneeControl({
|
||||
side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm",
|
||||
side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm", kind = "operator",
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const current = operators.find((o) => o.id === assigneeId);
|
||||
const label = current ? current.username : "Unassigned";
|
||||
const eligible = operators.filter((o) => SIDE_ROLES[side].includes(o.role));
|
||||
const sideLabel = side === "red" ? "RT" : "BT";
|
||||
const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role));
|
||||
const sideLabel = LABEL_PREFIX[kind][side];
|
||||
|
||||
const badgeClass =
|
||||
size === "lg"
|
||||
|
||||
@@ -93,7 +93,10 @@ interface TestDetailHeaderProps {
|
||||
onUpdateClassification: (value: DataClassification) => void;
|
||||
isUpdatingClassification: boolean;
|
||||
operators: OperatorOut[];
|
||||
onAssignOperator: (side: "red" | "blue", userId: string | null) => void;
|
||||
onAssignOperator: (
|
||||
field: "red_tech_assignee" | "blue_tech_assignee" | "red_reviewer_assignee" | "blue_reviewer_assignee",
|
||||
userId: string | null,
|
||||
) => void;
|
||||
isAssigningOperator: boolean;
|
||||
}
|
||||
|
||||
@@ -551,13 +554,37 @@ export default function TestDetailHeader({
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{test.state === "red_review" && (
|
||||
<AssigneeControl
|
||||
side="red"
|
||||
kind="reviewer"
|
||||
assigneeId={test.red_reviewer_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "red_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("red_reviewer_assignee", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
{test.state === "blue_review" && (
|
||||
<AssigneeControl
|
||||
side="blue"
|
||||
kind="reviewer"
|
||||
assigneeId={test.blue_reviewer_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "blue_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("blue_reviewer_assignee", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
<AssigneeControl
|
||||
side="red"
|
||||
assigneeId={test.red_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "red_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("red", userId)}
|
||||
onAssign={(userId) => onAssignOperator("red_tech_assignee", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
<AssigneeControl
|
||||
@@ -566,7 +593,7 @@ export default function TestDetailHeader({
|
||||
operators={operators}
|
||||
canEdit={role === "blue_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("blue", userId)}
|
||||
onAssign={(userId) => onAssignOperator("blue_tech_assignee", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -385,8 +385,12 @@ export default function TestDetailPage() {
|
||||
});
|
||||
|
||||
const assignOperatorMutation = useMutation({
|
||||
mutationFn: ({ side, userId }: { side: "red" | "blue"; userId: string | null }) =>
|
||||
assignTestOperators(testId!, side === "red" ? { red_tech_assignee: userId } : { blue_tech_assignee: userId }),
|
||||
mutationFn: ({
|
||||
field, userId,
|
||||
}: {
|
||||
field: "red_tech_assignee" | "blue_tech_assignee" | "red_reviewer_assignee" | "blue_reviewer_assignee";
|
||||
userId: string | null;
|
||||
}) => assignTestOperators(testId!, { [field]: userId }),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
showToast("Assignment updated", "success");
|
||||
@@ -559,7 +563,7 @@ export default function TestDetailPage() {
|
||||
onUpdateClassification={(value) => updateClassificationMutation.mutate(value)}
|
||||
isUpdatingClassification={updateClassificationMutation.isPending}
|
||||
operators={operators}
|
||||
onAssignOperator={(side, userId) => assignOperatorMutation.mutate({ side, userId })}
|
||||
onAssignOperator={(field, userId) => assignOperatorMutation.mutate({ field, userId })}
|
||||
isAssigningOperator={assignOperatorMutation.isPending}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user