|
7 | 7 | import logging |
8 | 8 | import re |
9 | 9 | from .. import schemas |
| 10 | +from ..retry_utils import seq_conflict_retry |
10 | 11 | from ..services.execution_timing import apply_test_result_execution_timing |
11 | 12 | from ..services.user_lifecycle import ( |
12 | 13 | create_user_invitation, |
@@ -602,42 +603,37 @@ def _auto_create_defect_for_failed_result(db: Session, test_result: TestResult, |
602 | 603 | # Get project_id from test run |
603 | 604 | project_id = test_run.project_id |
604 | 605 |
|
605 | | - # Find the next defect number for this project |
606 | | - all_defects = db.query(Defect).filter( |
607 | | - Defect.project_id == project_id |
608 | | - ).all() |
609 | | - |
610 | | - max_defect_number = 0 |
611 | | - for existing_defect in all_defects: |
612 | | - if existing_defect.defect_id: |
613 | | - import re |
614 | | - match = re.search(r'(?:P\d+-)?DEF-(\d+)', existing_defect.defect_id) |
615 | | - if match: |
616 | | - defect_number = int(match.group(1)) |
617 | | - if defect_number > max_defect_number: |
618 | | - max_defect_number = defect_number |
619 | | - |
620 | | - # Generate defect_id |
621 | | - defect_id = f"P{project_id}-DEF-{max_defect_number + 1:03d}" |
622 | | - |
623 | | - # Create the defect |
624 | | - new_defect = Defect( |
625 | | - title=defect_title, |
626 | | - description=defect_description, |
627 | | - defect_id=defect_id, |
628 | | - status=DefectStatus.OPEN, |
629 | | - severity=DefectSeverity.MEDIUM, |
630 | | - priority=DefectPriority.MEDIUM, |
631 | | - project_id=project_id, |
632 | | - test_case_id=test_case.id, |
633 | | - test_run_id=test_run.id, |
634 | | - reported_by=test_result.executed_by or 1, # Use executor or fallback to user 1 |
635 | | - actual_result=test_result.actual_result, |
636 | | - ) |
| 606 | + # Numbering is owned centrally by the project_seq before_insert listener |
| 607 | + # (services/sequence_service.py), which derives defect_id as |
| 608 | + # P{project_id}-DEF-{seq:03d}. Don't recompute it here: a second, manual |
| 609 | + # max-scan (the old full-table SELECT-all) is a divergent source of truth |
| 610 | + # that can disagree with the URL/badge number. We leave defect_id unset |
| 611 | + # and let the listener allocate it. |
| 612 | + # |
| 613 | + # MAX(project_seq)+1 can still race two concurrent failed-result inserts |
| 614 | + # into a unique-index (project_id, project_seq) collision — surfaced as an |
| 615 | + # IntegrityError on commit. Retry the whole build+commit so a fresh |
| 616 | + # instance re-runs allocation and picks the next free number. |
| 617 | + @seq_conflict_retry() |
| 618 | + def _insert_defect() -> Defect: |
| 619 | + new_defect = Defect( |
| 620 | + title=defect_title, |
| 621 | + description=defect_description, |
| 622 | + status=DefectStatus.OPEN, |
| 623 | + severity=DefectSeverity.MEDIUM, |
| 624 | + priority=DefectPriority.MEDIUM, |
| 625 | + project_id=project_id, |
| 626 | + test_case_id=test_case.id, |
| 627 | + test_run_id=test_run.id, |
| 628 | + reported_by=test_result.executed_by or 1, # executor or fallback to user 1 |
| 629 | + actual_result=test_result.actual_result, |
| 630 | + ) |
| 631 | + db.add(new_defect) |
| 632 | + safe_commit(db) |
| 633 | + db.refresh(new_defect) |
| 634 | + return new_defect |
637 | 635 |
|
638 | | - db.add(new_defect) |
639 | | - safe_commit(db) |
640 | | - db.refresh(new_defect) |
| 636 | + new_defect = _insert_defect() |
641 | 637 |
|
642 | 638 | # Create a link between the test result and the new defect |
643 | 639 | defect_link = TestResultDefectLink( |
|
0 commit comments