Skip to content

PM-3907 - ai review status#1528

Merged
kkartunov merged 2 commits intodevfrom
PM-3907_ai-review-status-in-review-table
Mar 12, 2026
Merged

PM-3907 - ai review status#1528
kkartunov merged 2 commits intodevfrom
PM-3907_ai-review-status-in-review-table

Conversation

@vas3a
Copy link
Collaborator

@vas3a vas3a commented Mar 12, 2026

Related JIRA Ticket:

https://topcoder.atlassian.net/browse/PM-3907

What's in this PR?

image image image image
Open with Devin

ev.stopPropagation()
}

function normalizeStatus(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The normalizeStatus function has a cyclomatic complexity that might be higher than necessary. Consider refactoring to simplify the logic and improve maintainability.

[runs],
)

const reviewerRows = useMemo<AiReviewerRow[]>(() => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The reviewerRows useMemo hook contains a lot of logic, including filtering, mapping, and conditionals. Consider breaking this into smaller functions to improve readability and maintainability.

[reviewerRows],
)

const lockMessage = useMemo(() => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
The lockMessage useMemo hook constructs a message using string concatenation. Consider using template literals for better readability.

+ 'Improve your submission and resubmit.'
}, [currentDecision?.submissionLocked, failedGatingReviewers])

if (isTablet) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The conditional rendering logic for the tablet view is complex and repeated for both mobile and desktop views. Consider extracting this logic into a separate component to avoid duplication and improve maintainability.


const aiReviewersCount = useMemo(() => {
const reviewersCount = props.aiReviewers.length || aiReviewConfig?.workflows?.length || 0
return reviewersCount + 1

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The calculation of aiReviewersCount assumes that either props.aiReviewers.length or aiReviewConfig?.workflows?.length is non-zero. If both are zero, this will incorrectly return 1. Consider handling this case explicitly to avoid potential confusion or errors.

</span>
</Tooltip>
)}
{formatScore(currentDecision!.totalScore)}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of the non-null assertion operator (!) on currentDecision!.totalScore assumes that currentDecision is always defined when hasScore is true. Ensure that this assumption holds in all cases, or consider adding a check to prevent potential runtime errors.

const aiReviewers = useMemo(() => props.aiReviewers ?? [], [props.aiReviewers])
const aiReviewersCount = useMemo(() => (aiReviewers.length ?? 0) + 1, [aiReviewers])
const aiReviewersCount = useMemo(
() => ((aiReviewers.length || aiReviewConfig?.workflows?.length || 0) + 1),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The calculation of aiReviewersCount uses a fallback value of 1 if both aiReviewers.length and aiReviewConfig?.workflows?.length are 0. This might lead to incorrect counts if there are no reviewers or workflows. Consider revising the logic to accurately reflect the count of reviewers.

{aiReviewersCount > 0 && (
<>
<td className={styles.cellVirusScan}>
{hasDecisionScore ? formatScore(currentDecision!.totalScore) : '-'}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
Using the non-null assertion operator (!) on currentDecision could lead to runtime errors if currentDecision is undefined. Consider adding a check to ensure currentDecision is defined before accessing totalScore.

{toggledRows.has(submission.id) && (
<tr>
<td className={styles.aiReviewersTableRow} colSpan={4}>
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount ? 6 : 4}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The colSpan attribute is dynamically set based on aiReviewersCount. Ensure that aiReviewersCount accurately reflects the number of columns to avoid layout issues.

const {
decisions: aiReviewDecisions,
isLoading: isLoadingAiReviewDecisions,
}: UseFetchAiReviewDecisionsResult = useFetchAiReviewDecisions(aiReviewConfig?.id)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The useFetchAiReviewDecisions hook is called with aiReviewConfig?.id. If aiReviewConfig is undefined, this will pass undefined to the hook, which may not be the intended behavior. Consider adding a check to ensure aiReviewConfig is defined before calling the hook.

}: UseFetchAiReviewDecisionsResult = useFetchAiReviewDecisions(aiReviewConfig?.id)

const aiReviewDecisionsBySubmissionId = useMemo(
() => aiReviewDecisions.reduce<Record<string, typeof aiReviewDecisions[number]>>((result, decision) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The reduce function in useMemo does not handle the case where aiReviewDecisions might be undefined or null. Ensure that aiReviewDecisions is always an array before calling reduce to prevent runtime errors.

try {
return await fetchAiReviewConfig(challengeId)
} catch (fetchError) {
if (isNotFoundError(fetchError)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
Consider logging the error details before returning undefined for a 404 error. This could help in debugging and understanding why a specific request was not found.

{
fetcher: async (): Promise<AiReviewDecision[]> => {
if (!configId) {
return []

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider handling potential errors in fetchAiReviewDecisions similar to how errors are handled in fetchAiReviewConfig. This would ensure consistency and robustness in error handling across different fetch operations.

id: string
challengeId: string
version: number
minPassingThreshold: number

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
Consider using a more descriptive type for minPassingThreshold to clarify its intended range and constraints, such as a custom type or a more specific numeric type.

challengeId: string
version: number
minPassingThreshold: number
mode: string

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The mode field is a string, which might be prone to errors if not validated. Consider using a union type or an enum to restrict possible values and improve type safety.

isGating: boolean
minimumPassingScore: number
runId: string | null
runStatus: string | null

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The runStatus field is a string, which could lead to inconsistencies if not properly validated. Consider using a union type or an enum to define the possible statuses.


export interface AiReviewDecisionBreakdown {
evaluatedAt?: string
mode?: string

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The mode field is optional and a string. Consider using a union type or an enum to define acceptable values, ensuring consistency and reducing potential errors.

registrants: BackendResource[]
reviewers: BackendResource[]
aiReviewConfig?: AiReviewConfig
aiReviewDecisionsBySubmissionId: Record<string, AiReviewDecision>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ design]
Consider making aiReviewDecisionsBySubmissionId optional if there are scenarios where AI review decisions might not be available. This would align with the optional nature of aiReviewConfig and improve the model's flexibility.


import { AiReviewConfig, AiReviewDecision } from '../models'

const v6BaseUrl = `${EnvironmentConfig.API.V6}`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider validating EnvironmentConfig.API.V6 to ensure it is a valid URL string. This will prevent potential runtime errors if the configuration is incorrect.

`${v6BaseUrl}/ai-review/configs/${challengeId ?? ''}`
)

export const fetchAiReviewConfig = async (challengeId: string): Promise<AiReviewConfig> => (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Ensure that challengeId is validated before being used in the URL. If challengeId is undefined or an empty string, it might lead to unexpected API behavior.

`${v6BaseUrl}/ai-review/decisions?configId=${configId ?? ''}`
)

export const fetchAiReviewDecisions = async (configId: string): Promise<AiReviewDecision[]> => (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Consider adding error handling for the xhrGetAsync call to manage potential network or API errors gracefully.

@@ -1,4 +1,5 @@
import { FC, Fragment, MouseEvent, useCallback, useMemo, useState } from 'react'
/* eslint-disable complexity */

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Disabling the complexity rule for the entire file can hide potential issues. Consider refactoring complex functions instead of disabling the rule globally.

= challengeDetailContext.aiReviewDecisionsBySubmissionId
const currentDecision = aiReviewDecisionsBySubmissionId[submission.id]
const hasDecisionScore = currentDecision?.totalScore !== null && currentDecision?.totalScore !== undefined
const normalizedStatus = normalizeDecisionStatus(currentDecision?.status ?? undefined)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The change from null to undefined in the normalizeDecisionStatus function call is subtle. Ensure that the function handles undefined as expected, as this could affect the logic if null and undefined are treated differently.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale closure in renderHistoryRow: missing challengeDetailContext, aiReviewersCount, and aiReviewers in useCallback dependencies

The renderHistoryRow callback (line 232) reads challengeDetailContext.aiReviewDecisionsBySubmissionId (line 289-291), aiReviewersCount (lines 312, 315, 320), and aiReviewers (line 337), but none of these are included in the dependency array (lines 344-351). Since aiReviewDecisionsBySubmissionId is populated asynchronously from an API response via context, the callback captures the initial empty {} value and never updates when decision data arrives. This causes the Score and Status columns to permanently show - in each history row, even after the data has loaded, until an unrelated dependency like toggledRows changes.

(Refers to lines 344-351)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

{toggledRows.has(submission.id) && (
<tr>
<td className={styles.aiReviewersTableRow} colSpan={4}>
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount ? 6 : 4}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Incorrect colSpan value (6) for expanded row when table only has 5 columns

The expanded row at line 335 uses colSpan={aiReviewersCount ? 6 : 4}. Since aiReviewersCount is always >= 1 (computed as (aiReviewers.length || aiReviewConfig?.workflows?.length || 0) + 1 at SubmissionHistoryModal.tsx:139), this always evaluates to 6. However, the table only has 5 columns: Submission ID, Submitted, Reviewer, Score, and Status (the Score/Status columns are always shown since aiReviewersCount > 0 is always true at line 320/371). The colSpan should be 5, not 6.

Suggested change
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount ? 6 : 4}>
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount ? 5 : 3}>
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Collaborator

@kkartunov kkartunov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great

@kkartunov kkartunov merged commit cf5d97e into dev Mar 12, 2026
11 checks passed
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds AI Review (config + decision) fetching and surfaces AI decision outcomes in the review UI (AI reviews table, collapsible row summary, and submission history modal) by extending the challenge detail context.

Changes:

  • Introduces new AI Review models, service calls, and SWR hooks to fetch AI review config and decisions from the V6 API.
  • Extends ChallengeDetailContext to provide AI review config/decisions + loading flags to downstream components.
  • Updates AI review UI components to render overall AI score/status, gating indicators, weights/min scores, and “submission locked” messaging.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/apps/review/src/lib/services/index.ts Exposes new AI review service from the services barrel.
src/apps/review/src/lib/services/aiReview.service.ts Adds API calls/cache keys for AI review config and decisions.
src/apps/review/src/lib/models/index.ts Exports new AI Review model types.
src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts Extends challenge detail context contract with AI review fields.
src/apps/review/src/lib/models/AiReview.model.ts Defines AI review config/decision DTO shapes used by UI and hooks.
src/apps/review/src/lib/hooks/useFetchAiReviewData.ts Adds SWR hooks to fetch AI review config and decisions.
src/apps/review/src/lib/hooks/index.ts Re-exports new AI review hooks.
src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx Fetches AI review config/decisions and places them into context.
src/apps/review/src/lib/contexts/ChallengeDetailContext.ts Adds defaults for new AI review context fields.
src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx Forwards aiReviewers into downstream history/details UI.
src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx Forwards aiReviewers into downstream history/details UI.
src/apps/review/src/lib/components/TableReview/TableReview.tsx Forwards aiReviewers into downstream history/details UI.
src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx Adds AI score/status columns and uses context decisions/config for display.
src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss Minor styling tweak for vertical alignment.
src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx Shows overall AI score/status summary and formula tooltip using context config/decisions.
src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss Adds layout/styling for the new summary header and tooltip.
src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx Extends status prop union to support a “failed” state.
src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx Merges configured workflows, decision breakdown, and workflow runs into a unified reviewer table + gating/lock UI.
src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss Adds styles for locked banner, gating marker, and row result coloring.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

export * from './resources.service'
export * from './payments.service'
export * from './challenge-phases.service'
export * from './aiReview.service'
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service filenames in this folder consistently use kebab-case (e.g. challenge-phases.service.ts, file-upload.service.ts). This new export uses ./aiReview.service, which breaks that convention and can also be error-prone on case-sensitive filesystems. Consider renaming the file/export to ai-review.service to match the established pattern.

Suggested change
export * from './aiReview.service'
export * from './ai-review.service'

Copilot uses AI. Check for mistakes.
registrants: BackendResource[]
reviewers: BackendResource[]
aiReviewConfig?: AiReviewConfig
aiReviewDecisionsBySubmissionId: Record<string, AiReviewDecision>
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aiReviewDecisionsBySubmissionId is typed as Record<string, AiReviewDecision>, but the code later indexes it and expects missing keys (e.g. const currentDecision = ...[submission.id]). With Record, indexed access is typed as always present, which can hide real undefined cases. Prefer Partial<Record<string, AiReviewDecision>> (or Record<string, AiReviewDecision | undefined>) to accurately model optional entries.

Suggested change
aiReviewDecisionsBySubmissionId: Record<string, AiReviewDecision>
aiReviewDecisionsBySubmissionId: Partial<Record<string, AiReviewDecision>>

Copilot uses AI. Check for mistakes.
Comment on lines 137 to +141
const aiReviewers = useMemo(() => props.aiReviewers ?? [], [props.aiReviewers])
const aiReviewersCount = useMemo(() => (aiReviewers.length ?? 0) + 1, [aiReviewers])
const aiReviewersCount = useMemo(
() => ((aiReviewers.length || aiReviewConfig?.workflows?.length || 0) + 1),
[aiReviewConfig?.workflows?.length, aiReviewers.length],
)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aiReviewersCount is computed as (reviewersOrWorkflowsCount + 1), so it will always be at least 1 even when there are no AI reviewers and no AI review config. As a result, checks like aiReviewersCount > 0 become effectively unconditional (always true) and the extra Score/Status columns will always render. Consider separating "has AI review columns" from the display count (or only adding +1 at the point where you render the label).

Copilot uses AI. Check for mistakes.
{toggledRows.has(submission.id) && (
<tr>
<td className={styles.aiReviewersTableRow} colSpan={4}>
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount ? 6 : 4}>
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expanded row colSpan doesn't match the number of columns rendered. The table currently renders 3 base columns plus 2 optional Score/Status columns (= 5), but the expanded row uses colSpan={aiReviewersCount ? 6 : 4}, which will typically evaluate to 6 and can break table layout/alignment. Compute colSpan directly from the same condition used to render the extra columns (e.g., 5 vs 3).

Suggested change
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount ? 6 : 4}>
<td className={styles.aiReviewersTableRow} colSpan={aiReviewersCount > 0 ? 5 : 3}>

Copilot uses AI. Check for mistakes.
Comment on lines +289 to +293
const aiReviewDecisionsBySubmissionId: ChallengeDetailContextModel['aiReviewDecisionsBySubmissionId']
= challengeDetailContext.aiReviewDecisionsBySubmissionId
const currentDecision = aiReviewDecisionsBySubmissionId[submission.id]
const hasDecisionScore = currentDecision?.totalScore !== null && currentDecision?.totalScore !== undefined
const normalizedStatus = normalizeDecisionStatus(currentDecision?.status ?? undefined)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These AI decision/config reads are inside renderHistoryRow, which is memoized with useCallback. The callback’s dependency list (later in the file) does not include challengeDetailContext / AI decisions or aiReviewersCount, so this row renderer can capture stale context values and not update when SWR/context data changes. Include the missing dependencies or avoid memoizing this renderer.

Copilot uses AI. Check for mistakes.
if (status === 'ERROR') {
return 'failed'
}

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function defaults to returning pending for any unhandled status. Since AiReviewDecisionStatus also includes HUMAN_OVERRIDE, that value would currently be displayed as pending. Please add an explicit mapping for HUMAN_OVERRIDE (and consider a clearer fallback for truly unknown statuses).

Suggested change
if (status === 'HUMAN_OVERRIDE') {
// Explicitly handle HUMAN_OVERRIDE; currently normalized as 'pending'
return 'pending'
}

Copilot uses AI. Check for mistakes.
content='Must pass independently to avoid automatic failure.'
triggerOn='hover'
>
<span className={styles.gatingMarker}>⚡</span>
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gating indicator is rendered as a raw "⚡" character inside a <span> with no accessible label. Screen readers may announce it ambiguously (or not at all). Please add an aria-label/title (or replace with an icon component that has accessible text) so gating is understandable for assistive tech users.

Suggested change
<span className={styles.gatingMarker}></span>
<span
className={styles.gatingMarker}
role='img'
aria-label='Gating review: must pass independently to avoid automatic failure.'
>
</span>

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants