Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1289,12 +1289,33 @@ async def review(self, context: PRContext) -> PRReviewResult:
f"{len(filtered_findings)} filtered"
)

# No confidence routing - validation is binary via finding-validator
unique_findings = validated_findings
logger.info(f"[PRReview] Final findings: {len(unique_findings)} validated")
# Separate active findings (drive verdict) from dismissed (shown in UI only)
active_findings = [
f
for f in validated_findings
if f.validation_status != "dismissed_false_positive"
]
dismissed_findings = [
f
for f in validated_findings
if f.validation_status == "dismissed_false_positive"
]
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

To improve efficiency and readability, you can partition the validated_findings into active_findings and dismissed_findings in a single loop instead of iterating over the list twice.

            active_findings = []
            dismissed_findings = []
            for f in validated_findings:
                if f.validation_status == "dismissed_false_positive":
                    dismissed_findings.append(f)
                else:
                    active_findings.append(f)


safe_print(
f"[ParallelOrchestrator] Final: {len(active_findings)} active, "
f"{len(dismissed_findings)} disputed by validator",
flush=True,
)
logger.info(
f"[PRReview] Final findings: {len(active_findings)} active, "
f"{len(dismissed_findings)} disputed"
)

# All findings (active + dismissed) go in the result for UI display
unique_findings = validated_findings
logger.info(
f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings"
f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings "
f"({len(active_findings)} active, {len(dismissed_findings)} disputed)"
)

# Fetch CI status for verdict consideration
Expand All @@ -1304,9 +1325,9 @@ async def review(self, context: PRContext) -> PRReviewResult:
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending"
)

# Generate verdict (includes merge conflict check, branch-behind check, and CI status)
# Generate verdict from ACTIVE findings only (dismissed don't affect verdict)
verdict, verdict_reasoning, blockers = self._generate_verdict(
unique_findings,
active_findings,
has_merge_conflicts=context.has_merge_conflicts,
merge_state_status=context.merge_state_status,
ci_status=ci_status,
Expand Down Expand Up @@ -1937,12 +1958,38 @@ async def _validate_findings(
validated_findings.append(finding)

elif validation.validation_status == "dismissed_false_positive":
# Dismiss - do not include
dismissed_count += 1
logger.info(
f"[PRReview] Dismissed {finding.id} as false positive: "
f"{validation.explanation[:100]}"
)
# Protect cross-validated findings from dismissal —
# if multiple specialists independently found the same issue,
# a single validator should not override that consensus
if finding.cross_validated:
finding.validation_status = "confirmed_valid"
finding.validation_evidence = validation.code_evidence
finding.validation_explanation = (
f"[Auto-kept: cross-validated by {len(finding.source_agents)} agents] "
f"{validation.explanation}"
)
validated_findings.append(finding)
safe_print(
f"[FindingValidator] Kept cross-validated finding '{finding.title}' "
f"despite dismissal (agents={finding.source_agents})",
flush=True,
)
else:
# Keep finding but mark as dismissed (user can see it in UI)
finding.validation_status = "dismissed_false_positive"
finding.validation_evidence = validation.code_evidence
finding.validation_explanation = validation.explanation
validated_findings.append(finding)
dismissed_count += 1
safe_print(
f"[FindingValidator] Disputed '{finding.title}': "
f"{validation.explanation} (file={finding.file}:{finding.line})",
flush=True,
)
logger.info(
f"[PRReview] Disputed {finding.id}: "
f"{validation.explanation[:200]}"
)
Comment on lines 1957 to +1989
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Cross-validation protection and dismissed-finding retention look correct.

Two downstream issues to address:

  1. Misleading log at line 2001: validated_findings now includes dismissed findings, so {len(validated_findings)} valid, {dismissed_count} dismissed double-counts dismissed items. Consider {len(validated_findings) - dismissed_count} confirmed, {dismissed_count} dismissed.

  2. Raw status string in summary (line 2205-2208): _generate_summary doesn't map "dismissed_false_positive" in the status_label dict, so the GitHub-rendered summary will display the raw string "dismissed_false_positive". Add a mapping entry.

Proposed fixes

Fix 1 — Accurate log at line 2000:

         logger.info(
-            f"[PRReview] Validation complete: {len(validated_findings)} valid, "
+            f"[PRReview] Validation complete: {len(validated_findings)} total "
+            f"({len(validated_findings) - dismissed_count} confirmed, "
             f"{dismissed_count} dismissed, {needs_human_count} need human review"
         )

Fix 2 — Add label mapping at line 2205:

                     status_label = {
                         "confirmed_valid": "Confirmed",
+                        "dismissed_false_positive": "Disputed by Validator",
                         "needs_human_review": "Needs human review",
                     }.get(f.validation_status, f.validation_status)
🤖 Prompt for AI Agents
In `@apps/backend/runners/github/services/parallel_orchestrator_reviewer.py`
around lines 1957 - 1989, The log that reports counts is double-counting
dismissed items because validated_findings includes both confirmed and dismissed
entries; update the logger.info message that currently formats
"{len(validated_findings)} valid, {dismissed_count} dismissed" to compute
confirmed as len(validated_findings) - dismissed_count (use validated_findings
and dismissed_count variables) so it logs "{confirmed} confirmed,
{dismissed_count} dismissed". Also update the _generate_summary implementation's
status_label dictionary to include a human-friendly mapping for the
"dismissed_false_positive" status (e.g., "Dismissed (False Positive)") so the
GitHub-rendered summary shows a readable label instead of the raw status string.


elif validation.validation_status == "needs_human_review":
# Keep but flag
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,10 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n
endLine: f.end_line,
suggestedFix: f.suggested_fix,
fixable: f.fixable ?? false,
validationStatus: f.validation_status ?? null,
validationExplanation: f.validation_explanation ?? undefined,
sourceAgents: f.source_agents ?? [],
crossValidated: f.cross_validated ?? false,
})) ?? [],
summary: data.summary ?? "",
overallStatus: data.overall_status ?? "comment",
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/preload/api/modules/github-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@ export interface PRReviewFinding {
endLine?: number;
suggestedFix?: string;
fixable: boolean;
validationStatus?: 'confirmed_valid' | 'dismissed_false_positive' | 'needs_human_review' | null;
validationExplanation?: string;
sourceAgents?: string[];
crossValidated?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface FindingItemProps {
finding: PRReviewFinding;
selected: boolean;
posted?: boolean;
disputed?: boolean;
onToggle: () => void;
}

Expand All @@ -33,7 +34,7 @@ function getCategoryTranslationKey(category: string): string {
return categoryMap[category.toLowerCase()] || category;
}

export function FindingItem({ finding, selected, posted = false, onToggle }: FindingItemProps) {
export function FindingItem({ finding, selected, posted = false, disputed = false, onToggle }: FindingItemProps) {
const { t } = useTranslation('common');
const CategoryIcon = getCategoryIcon(finding.category);

Expand All @@ -45,8 +46,9 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
<div
className={cn(
"rounded-lg border bg-background p-3 space-y-2 transition-colors",
selected && !posted && "ring-2 ring-primary/50",
posted && "opacity-60"
selected && !posted && !disputed && "ring-2 ring-primary/50",
selected && disputed && "ring-2 ring-purple-500/50",
(posted || disputed) && "opacity-60"
)}
>
{/* Finding Header */}
Expand All @@ -72,13 +74,28 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
{t('prReview.posted')}
</Badge>
)}
{disputed && (
<Badge variant="outline" className="text-xs shrink-0 bg-purple-500/10 text-purple-500 border-purple-500/30">
{t('prReview.disputed')}
</Badge>
)}
{finding.crossValidated && finding.sourceAgents && finding.sourceAgents.length > 1 && (
<Badge variant="outline" className="text-xs shrink-0 bg-green-500/10 text-green-500 border-green-500/30">
{t('prReview.crossValidatedBy', { count: finding.sourceAgents.length })}
</Badge>
)}
<span className="font-medium text-sm break-words">
{finding.title}
</span>
</div>
<p className="text-sm text-muted-foreground break-words">
{finding.description}
</p>
{disputed && finding.validationExplanation && (
<p className="text-xs text-purple-500/80 italic break-words">
{finding.validationExplanation}
</p>
)}
<div className="text-xs text-muted-foreground">
<code className="bg-muted px-1 py-0.5 rounded break-all">
{finding.file}:{finding.line}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import type { PRReviewFinding } from '../hooks/useGitHubPRs';
interface FindingsSummaryProps {
findings: PRReviewFinding[];
selectedCount: number;
disputedCount?: number;
}

export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProps) {
export function FindingsSummary({ findings, selectedCount, disputedCount = 0 }: FindingsSummaryProps) {
const { t } = useTranslation('common');

// Count findings by severity
Expand Down Expand Up @@ -46,6 +47,11 @@ export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProp
{counts.low} {t('prReview.severity.low')}
</Badge>
)}
{disputedCount > 0 && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/30">
{disputedCount} {t('prReview.disputed')}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{t('prReview.selectedOfTotal', { selected: selectedCount, total: counts.total })}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* - Quick select actions (Critical/High, All, None)
* - Collapsible sections for less important findings
* - Visual summary of finding counts
* - Disputed findings shown in a separate collapsible section
*/

import { useState, useMemo } from 'react';
Expand All @@ -16,6 +17,9 @@ import {
CheckSquare,
Square,
Send,
ChevronDown,
ChevronRight,
ShieldQuestion,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/button';
Expand Down Expand Up @@ -47,17 +51,29 @@ export function ReviewFindings({
const [expandedSections, setExpandedSections] = useState<Set<SeverityGroup>>(
new Set<SeverityGroup>(['critical', 'high']) // Critical and High expanded by default
);
const [disputedExpanded, setDisputedExpanded] = useState(false);

// Filter out posted findings - only show unposted findings for selection
const unpostedFindings = useMemo(() =>
findings.filter(f => !postedIds.has(f.id)),
[findings, postedIds]
);

// Split unposted findings into active vs disputed
const activeFindings = useMemo(() =>
unpostedFindings.filter(f => f.validationStatus !== 'dismissed_false_positive'),
[unpostedFindings]
);

const disputedFindings = useMemo(() =>
unpostedFindings.filter(f => f.validationStatus === 'dismissed_false_positive'),
[unpostedFindings]
);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

To improve efficiency, you can split unpostedFindings into activeFindings and disputedFindings in a single pass using useMemo. This avoids iterating over the unpostedFindings array twice.

Suggested change
// Split unposted findings into active vs disputed
const activeFindings = useMemo(() =>
unpostedFindings.filter(f => f.validationStatus !== 'dismissed_false_positive'),
[unpostedFindings]
);
const disputedFindings = useMemo(() =>
unpostedFindings.filter(f => f.validationStatus === 'dismissed_false_positive'),
[unpostedFindings]
);
const { activeFindings, disputedFindings } = useMemo(() => {
const active: PRReviewFinding[] = [];
const disputed: PRReviewFinding[] = [];
for (const finding of unpostedFindings) {
if (finding.validationStatus === 'dismissed_false_positive') {
disputed.push(finding);
} else {
active.push(finding);
}
}
return { activeFindings: active, disputedFindings: disputed };
}, [unpostedFindings]);


// Check if all findings are posted
const allFindingsPosted = findings.length > 0 && unpostedFindings.length === 0;

// Group unposted findings by severity (only show findings that haven't been posted)
// Group ACTIVE unposted findings by severity (disputed go in their own section)
const groupedFindings = useMemo(() => {
const groups: Record<SeverityGroup, PRReviewFinding[]> = {
critical: [],
Expand All @@ -66,36 +82,36 @@ export function ReviewFindings({
low: [],
};

for (const finding of unpostedFindings) {
for (const finding of activeFindings) {
const severity = finding.severity as SeverityGroup;
if (groups[severity]) {
groups[severity].push(finding);
}
}

return groups;
}, [unpostedFindings]);
}, [activeFindings]);

// Count by severity (unposted findings only)
// Count by severity (active findings only)
const counts = useMemo(() => ({
critical: groupedFindings.critical.length,
high: groupedFindings.high.length,
medium: groupedFindings.medium.length,
low: groupedFindings.low.length,
total: unpostedFindings.length,
total: activeFindings.length,
important: groupedFindings.critical.length + groupedFindings.high.length,
posted: postedIds.size,
}), [groupedFindings, unpostedFindings.length, postedIds.size]);
}), [groupedFindings, activeFindings.length, postedIds.size]);

// Selection hooks - use unposted findings only
// Selection hooks - use ACTIVE unposted findings only (Select All excludes disputed)
const {
toggleFinding,
selectAll,
selectNone,
selectImportant,
toggleSeverityGroup,
} = useFindingSelection({
findings: unpostedFindings,
findings: activeFindings,
selectedIds,
onSelectionChange,
groupedFindings,
Expand Down Expand Up @@ -131,10 +147,11 @@ export function ReviewFindings({

return (
<div className="space-y-4">
{/* Summary Stats Bar - show unposted findings only */}
{/* Summary Stats Bar - show active findings + disputed count */}
<FindingsSummary
findings={unpostedFindings}
findings={activeFindings}
selectedCount={selectedIds.size}
disputedCount={disputedFindings.length}
/>

{/* Quick Select Actions */}
Expand Down Expand Up @@ -170,7 +187,7 @@ export function ReviewFindings({
</Button>
</div>

{/* Grouped Findings (unposted only) */}
{/* Grouped Findings (active only) */}
<div className="space-y-3">
{SEVERITY_ORDER.map((severity) => {
const group = groupedFindings[severity];
Expand Down Expand Up @@ -220,6 +237,47 @@ export function ReviewFindings({
})}
</div>

{/* Disputed Findings Section */}
{disputedFindings.length > 0 && (
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5">
{/* Disputed Header */}
<button
type="button"
onClick={() => setDisputedExpanded(!disputedExpanded)}
className="w-full flex items-center gap-2 p-3 text-left hover:bg-purple-500/10 transition-colors rounded-t-lg"
>
{disputedExpanded ? (
<ChevronDown className="h-4 w-4 text-purple-500 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-purple-500 shrink-0" />
)}
<ShieldQuestion className="h-4 w-4 text-purple-500 shrink-0" />
<span className="text-sm font-medium text-purple-500">
{t('prReview.disputedByValidator', { count: disputedFindings.length })}
</span>
</button>

{/* Disputed Content */}
{disputedExpanded && (
<div className="p-3 pt-0 space-y-2">
<p className="text-xs text-muted-foreground italic mb-2">
{t('prReview.disputedSectionHint')}
</p>
{disputedFindings.map((finding) => (
<FindingItem
key={finding.id}
finding={finding}
selected={selectedIds.has(finding.id)}
posted={false}
disputed
onToggle={() => toggleFinding(finding.id)}
/>
))}
</div>
)}
</div>
)}

{/* Empty State - no findings at all */}
{findings.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/shared/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@
"verifyChanges": "Verify Changes",
"verifyAnyway": "Verify",
"runFollowupAnyway": "Run follow-up verification even though no files overlap",
"disputed": "Disputed",
"disputedByValidator": "Disputed by Validator ({{count}})",
"disputedExplanation": "The AI validator disagrees with this finding",
"crossValidatedBy": "Confirmed by {{count}} agents",
"disputedSectionHint": "These findings were reported by specialists but disputed by the validator. You can still select and post them.",
"logs": {
"agentActivity": "Agent Activity",
"showMore": "Show {{count}} more",
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/shared/i18n/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@
"blockedStatusMessageTitle": "## 🤖 Auto Claude PR Review",
"blockedStatusMessageFooter": "*This review identified blockers that must be resolved before merge. Generated by Auto Claude.*",
"failedPostBlockedStatus": "Échec de la publication du statut",
"disputed": "Contesté",
"disputedByValidator": "Contesté par le validateur ({{count}})",
"disputedExplanation": "Le validateur IA est en désaccord avec cette conclusion",
"crossValidatedBy": "Confirmé par {{count}} agents",
"disputedSectionHint": "Ces résultats ont été signalés par les spécialistes mais contestés par le validateur. Vous pouvez toujours les sélectionner et les publier.",
"logs": {
"agentActivity": "Activité des agents",
"showMore": "Afficher {{count}} de plus",
Expand Down
Loading