From 6c477fdc39e754cb1c1887d8c09156273d1cc1cf Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 16 Oct 2026 19:23:53 +0300 Subject: [PATCH] PM-6175 - allow copilot to trigger ai workflow --- .../AiReviewsTable/AiReviewsTable.spec.tsx | 47 +++++++++++++++---- .../AiReviewsTable/AiReviewsTable.tsx | 19 +++++--- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.spec.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.spec.tsx index 4d61195ea..e73f88532 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.spec.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.spec.tsx @@ -9,6 +9,13 @@ import type { AiReviewDecision, ChallengeDetailContextModel } from '../../models import AiReviewsTable from './AiReviewsTable' +const mockRolePermissions = { + hasCopilotRole: false, + hasSubmitterRole: false, + isAdmin: false, + isProjectManager: false, +} + jest.mock('../../contexts', () => { const React: typeof import('react') = jest.requireActual('react') @@ -40,10 +47,14 @@ jest.mock('~/libs/shared', () => ({ }), { virtual: true }) jest.mock('~/libs/ui', () => { - const Icon = (): JSX.Element => + const icons: Record JSX.Element> = {} + const getIcon = (name: string): (() => JSX.Element) => { + icons[name] ??= () => + return icons[name] + } return { - IconOutline: new Proxy({}, { get: () => Icon }), + IconOutline: new Proxy({}, { get: (target, name) => getIcon(String(name)) }), Tooltip: (props: PropsWithChildren) => <>{props.children}, } }, { virtual: true }) @@ -59,12 +70,7 @@ jest.mock('swr', () => ({ jest.mock('../../hooks', () => ({ ...jest.requireActual('../../hooks/useFetchAiWorkflowRuns'), useFetchAiWorkflowsRuns: jest.fn(), - useRolePermissions: () => ({ - hasCopilotRole: false, - hasSubmitterRole: false, - isAdmin: false, - isProjectManager: false, - }), + useRolePermissions: () => mockRolePermissions, })) jest.mock('../../assets/icons', () => ({ @@ -80,7 +86,12 @@ jest.mock('../../services/aiReview.service', () => ({ })) jest.mock('./AiWorkflowRunStatus', () => ({ - AiWorkflowRunStatus: (props: { status?: string }) => {`status:${props.status}`}, + AiWorkflowRunStatus: (props: { action?: JSX.Element, status?: string }) => ( + + {`status:${props.status}`} + {props.action} + + ), })) const mockedUseFetchAiWorkflowsRuns = useFetchAiWorkflowsRuns as jest.Mock @@ -164,6 +175,24 @@ const renderWorkflowRow = (run: AiWorkflowRun): HTMLElement => { describe('AiReviewsTable', () => { afterEach(() => { mockedUseFetchAiWorkflowsRuns.mockReset() + mockRolePermissions.hasCopilotRole = false + mockRolePermissions.isAdmin = false + }) + + it('hides the re-run action from users that are neither admin nor copilot', () => { + const row = within(renderWorkflowRow(buildRun({}))) + + expect(row.queryByTestId('icon-RefreshIcon')) + .toBeNull() + }) + + it('lets the challenge copilot re-run a workflow', () => { + mockRolePermissions.hasCopilotRole = true + + const row = within(renderWorkflowRow(buildRun({}))) + + expect(row.getByTestId('icon-RefreshIcon')) + .toBeTruthy() }) it('shows the edited score of a finished run', () => { diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 413315210..deb516f69 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -352,6 +352,11 @@ const AiReviewsTable: FC = props => { */ const canSeeAuthor = isAdmin || hasCopilotRole || isProjectManager + /** + * Admins and the challenge copilot can manually queue / re-run the AI workflows. + */ + const canTriggerWorkflows = isAdmin || hasCopilotRole + const handleRerun = useCallback(async (runId?: string): Promise => { if (!runId || runId === '-1') return @@ -391,11 +396,6 @@ const AiReviewsTable: FC = props => { } }, [mutate, props.submission.id]) - /** - * Admin only action rendered next to the run result: - * - a workflow with no run at all can be queued manually - * - an existing (finished) run can be re-run - */ const handleRebuildDecision = useCallback(async (): Promise => { if (!isAdmin || !props.submission.id) { return @@ -418,8 +418,13 @@ const AiReviewsTable: FC = props => { } }, [aiReviewConfig?.id, isAdmin, mutate, props.submission.id]) + /** + * Admin & copilot action rendered next to the run result: + * - a workflow with no run at all can be queued manually + * - an existing (finished) run can be re-run + */ const buildRowAction = useCallback((row: AiReviewerRow): ReactNode => { - if (!isAdmin || row.run?.id === '-1') { + if (!canTriggerWorkflows || row.run?.id === '-1') { return undefined } @@ -448,7 +453,7 @@ const AiReviewsTable: FC = props => { /> ) - }, [handleQueueMissingRuns, handleRerun, isAdmin, props.submission.virusScan, queueingRuns]) + }, [canTriggerWorkflows, handleQueueMissingRuns, handleRerun, props.submission.virusScan, queueingRuns]) const failedGatingReviewers = useMemo( () => reviewerRows