1313import java .util .*;
1414import java .util .stream .Collectors ;
1515
16+ import org .springframework .dao .DataIntegrityViolationException ;
17+
1618/**
1719 * Handles mapping of {@link CodeAnalysisIssue} records to
1820 * {@link BranchIssue} records with content-based deduplication.
@@ -42,6 +44,27 @@ public BranchIssueMappingService(CodeAnalysisIssueRepository codeAnalysisIssueRe
4244 public void mapCodeAnalysisIssuesToBranch (Set <String > changedFiles ,
4345 Set <String > filesExistingInBranch ,
4446 Branch branch , Project project ) {
47+
48+ // ── Build branch-wide content fingerprint set ─────────────────────────
49+ // The unique constraint uq_branch_issue_content_fp is on (branch_id, content_fingerprint)
50+ // so dedup must be branch-wide, not per-file. Pre-load all existing fingerprints once.
51+ List <BranchIssue > allBranchIssues = branchIssueRepository .findByBranchId (branch .getId ());
52+
53+ Set <String > branchContentFingerprints = new HashSet <>();
54+ Set <Long > allLinkedOriginIds = new HashSet <>();
55+ for (BranchIssue bi : allBranchIssues ) {
56+ if (bi .getContentFingerprint () != null ) {
57+ branchContentFingerprints .add (bi .getContentFingerprint ());
58+ }
59+ if (bi .getOriginIssue () != null ) {
60+ allLinkedOriginIds .add (bi .getOriginIssue ().getId ());
61+ }
62+ }
63+
64+ log .debug ("Branch {} pre-loaded {} content fingerprints and {} origin IDs for dedup" ,
65+ branch .getBranchName (), branchContentFingerprints .size (), allLinkedOriginIds .size ());
66+
67+ // ── Per-file mapping loop ─────────────────────────────────────────────
4568 for (String filePath : changedFiles ) {
4669 if (!filesExistingInBranch .contains (filePath )) {
4770 log .debug ("Skipping issue mapping for file {} - does not exist in branch {} (cached)" ,
@@ -61,41 +84,32 @@ public void mapCodeAnalysisIssuesToBranch(Set<String> changedFiles,
6184 unresolvedIssues .size (), filePath , allIssues .size ());
6285 }
6386
64- // Build deduplication maps from ALL existing BranchIssues (resolved + unresolved )
65- List <BranchIssue > existingBranchIssues = branchIssueRepository
87+ // Per-file legacy key map (legacy keys are file-scoped by construction )
88+ List <BranchIssue > existingBranchIssuesForFile = branchIssueRepository
6689 .findByBranchIdAndFilePath (branch .getId (), filePath );
6790
68- Map <String , BranchIssue > contentFpMap = new HashMap <>();
6991 Map <String , BranchIssue > legacyKeyMap = new HashMap <>();
70- for (BranchIssue bi : existingBranchIssues ) {
71- if (bi .getContentFingerprint () != null ) {
72- contentFpMap .putIfAbsent (bi .getContentFingerprint (), bi );
73- }
92+ for (BranchIssue bi : existingBranchIssuesForFile ) {
7493 legacyKeyMap .putIfAbsent (buildLegacyContentKey (bi ), bi );
7594 }
7695
77- Set <Long > linkedOriginIds = existingBranchIssues .stream ()
78- .filter (bi -> bi .getOriginIssue () != null )
79- .map (bi -> bi .getOriginIssue ().getId ())
80- .collect (Collectors .toSet ());
81-
8296 int skipped = 0 ;
8397 int mapped = 0 ;
8498 for (CodeAnalysisIssue issue : unresolvedIssues ) {
85- // Tier 1: origin ID match
86- if (linkedOriginIds .contains (issue .getId ())) {
99+ // Tier 1: origin ID match (branch-wide)
100+ if (allLinkedOriginIds .contains (issue .getId ())) {
87101 updateSeverityIfChanged (branch , issue );
88102 continue ;
89103 }
90104
91- // Tier 2: content fingerprint dedup
105+ // Tier 2: content fingerprint dedup (branch-wide — matches DB constraint scope)
92106 if (issue .getContentFingerprint () != null
93- && contentFpMap . containsKey (issue .getContentFingerprint ())) {
107+ && branchContentFingerprints . contains (issue .getContentFingerprint ())) {
94108 skipped ++;
95109 continue ;
96110 }
97111
98- // Tier 3: legacy key dedup
112+ // Tier 3: legacy key dedup (per-file)
99113 String legacyKey = buildLegacyContentKeyFromCAI (issue );
100114 if (legacyKeyMap .containsKey (legacyKey )) {
101115 skipped ++;
@@ -104,15 +118,27 @@ public void mapCodeAnalysisIssuesToBranch(Set<String> changedFiles,
104118
105119 // No match — create new BranchIssue as a full deep copy
106120 BranchIssue bi = BranchIssue .fromCodeAnalysisIssue (issue , branch );
107- branchIssueRepository .saveAndFlush (bi );
121+ try {
122+ branchIssueRepository .saveAndFlush (bi );
123+ } catch (DataIntegrityViolationException e ) {
124+ // Safety net: concurrent insert or edge-case fingerprint collision
125+ log .warn ("Duplicate content_fingerprint for branch {} file {} — skipping (fp={})" ,
126+ branch .getId (), filePath ,
127+ bi .getContentFingerprint () != null ? bi .getContentFingerprint ().substring (0 , 12 ) + "..." : "null" );
128+ skipped ++;
129+ if (bi .getContentFingerprint () != null ) {
130+ branchContentFingerprints .add (bi .getContentFingerprint ());
131+ }
132+ continue ;
133+ }
108134 mapped ++;
109135
110- // Register in maps so subsequent issues in this batch also dedup
136+ // Register in branch-wide maps so subsequent issues also dedup
111137 if (bi .getContentFingerprint () != null ) {
112- contentFpMap . put (bi .getContentFingerprint (), bi );
138+ branchContentFingerprints . add (bi .getContentFingerprint ());
113139 }
114140 legacyKeyMap .put (buildLegacyContentKey (bi ), bi );
115- linkedOriginIds .add (issue .getId ());
141+ allLinkedOriginIds .add (issue .getId ());
116142 }
117143
118144 if (mapped > 0 || skipped > 0 ) {
0 commit comments