|
1 | 1 | from datetime import datetime, timezone |
2 | 2 |
|
3 | 3 | from loguru import logger |
| 4 | +from pydantic import TypeAdapter |
4 | 5 | from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed |
5 | 6 |
|
6 | 7 | from crowdgit.enums import RepositoryPriority, RepositoryState |
7 | 8 | from crowdgit.errors import RepoLockingError |
| 9 | +from crowdgit.models.affiliation_info import AffiliationInfoItem |
8 | 10 | from crowdgit.models.repository import Repository |
9 | 11 | from crowdgit.models.service_execution import ServiceExecution |
10 | 12 | from crowdgit.settings import ( |
@@ -524,3 +526,284 @@ async def save_service_execution(service_execution: ServiceExecution) -> None: |
524 | 526 | f"error: {e}" |
525 | 527 | ) |
526 | 528 | # Do not re-raise - we don't want metrics saving to disrupt main operations |
| 529 | + |
| 530 | + |
| 531 | +_AFFILIATION_SNAPSHOT_ADAPTER = TypeAdapter(list[AffiliationInfoItem]) |
| 532 | + |
| 533 | + |
| 534 | +def parse_affiliation_snapshot(snapshot) -> list[AffiliationInfoItem]: |
| 535 | + if isinstance(snapshot, dict) and "affiliations" in snapshot: |
| 536 | + snapshot = snapshot["affiliations"] |
| 537 | + return _AFFILIATION_SNAPSHOT_ADAPTER.validate_python(snapshot) |
| 538 | + |
| 539 | + |
| 540 | +def dump_affiliation_snapshot(affiliations: list[AffiliationInfoItem]) -> list[dict]: |
| 541 | + return [item.model_dump() for item in affiliations] |
| 542 | + |
| 543 | + |
| 544 | +async def get_repo_affiliation_registry(repo_id: str): |
| 545 | + sql_query = """ |
| 546 | + SELECT "filePath", "fileSha", "status", "snapshot", "lastRunAt" |
| 547 | + FROM git."repoAffiliationRegistry" |
| 548 | + WHERE "repoId" = $1 |
| 549 | + """ |
| 550 | + result = await fetchrow(sql_query, (repo_id,)) |
| 551 | + if not result: |
| 552 | + return None |
| 553 | + |
| 554 | + row = dict(result) |
| 555 | + snapshot = row.get("snapshot") |
| 556 | + if snapshot is not None: |
| 557 | + snapshot = parse_affiliation_snapshot(snapshot) |
| 558 | + |
| 559 | + return { |
| 560 | + "file_path": row.get("filePath"), |
| 561 | + "file_sha": row.get("fileSha"), |
| 562 | + "status": row.get("status"), |
| 563 | + "snapshot": snapshot, |
| 564 | + "last_run_at": row.get("lastRunAt"), |
| 565 | + } |
| 566 | + |
| 567 | + |
| 568 | +async def upsert_repo_affiliation_registry( |
| 569 | + repo_id: str, |
| 570 | + *, |
| 571 | + file_path: str | None, |
| 572 | + file_sha: str | None, |
| 573 | + status: str, |
| 574 | + snapshot: list[AffiliationInfoItem] | None, |
| 575 | +) -> None: |
| 576 | + snapshot_json = dump_affiliation_snapshot(snapshot) if snapshot is not None else None |
| 577 | + sql_query = """ |
| 578 | + INSERT INTO git."repoAffiliationRegistry" ( |
| 579 | + "repoId", "filePath", "fileSha", "status", "snapshot", "lastRunAt", "updatedAt" |
| 580 | + ) |
| 581 | + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) |
| 582 | + ON CONFLICT ("repoId") DO UPDATE SET |
| 583 | + "filePath" = EXCLUDED."filePath", |
| 584 | + "fileSha" = EXCLUDED."fileSha", |
| 585 | + "status" = EXCLUDED."status", |
| 586 | + "snapshot" = EXCLUDED."snapshot", |
| 587 | + "lastRunAt" = NOW(), |
| 588 | + "updatedAt" = NOW() |
| 589 | + """ |
| 590 | + await execute( |
| 591 | + sql_query, |
| 592 | + (repo_id, file_path, file_sha, status, snapshot_json), |
| 593 | + ) |
| 594 | + |
| 595 | + |
| 596 | +async def find_many_member_ids_by_identities(identities: list[dict]) -> list[dict]: |
| 597 | + if not identities: |
| 598 | + return [] |
| 599 | + |
| 600 | + values_parts: list[str] = [] |
| 601 | + params: list[str | bool | int] = [] |
| 602 | + param_index = 1 |
| 603 | + for idx, identity in enumerate(identities): |
| 604 | + values_parts.append( |
| 605 | + f"(${param_index}, ${param_index + 1}, ${param_index + 2}, ${param_index + 3}, ${param_index + 4})" |
| 606 | + ) |
| 607 | + params.extend( |
| 608 | + [ |
| 609 | + idx, |
| 610 | + identity["type"], |
| 611 | + identity.get("verified", True), |
| 612 | + identity.get("platform"), |
| 613 | + identity["value"], |
| 614 | + ] |
| 615 | + ) |
| 616 | + param_index += 5 |
| 617 | + |
| 618 | + matches_by_idx: dict[int, set[str]] = {} |
| 619 | + rows = await query( |
| 620 | + f""" |
| 621 | + WITH input_identities (idx, identity_type, verified, platform, value) AS ( |
| 622 | + VALUES {", ".join(values_parts)} |
| 623 | + ) |
| 624 | + SELECT i.idx, mi."memberId" |
| 625 | + FROM input_identities i |
| 626 | + LEFT JOIN "memberIdentities" mi |
| 627 | + ON mi.type = i.identity_type |
| 628 | + AND mi.verified = i.verified |
| 629 | + AND lower(mi.value) = lower(i.value) |
| 630 | + AND (i.platform IS NULL OR mi.platform = i.platform) |
| 631 | + AND mi."deletedAt" IS NULL |
| 632 | + ORDER BY i.idx |
| 633 | + """, |
| 634 | + tuple(params), |
| 635 | + ) |
| 636 | + for row in rows: |
| 637 | + if row["memberId"] is None: |
| 638 | + continue |
| 639 | + matches_by_idx.setdefault(row["idx"], set()).add(str(row["memberId"])) |
| 640 | + |
| 641 | + results: list[dict] = [] |
| 642 | + for idx, identity in enumerate(identities): |
| 643 | + member_ids = matches_by_idx.get(idx, set()) |
| 644 | + member_id = next(iter(member_ids)) if len(member_ids) == 1 else None |
| 645 | + results.append( |
| 646 | + { |
| 647 | + "type": identity["type"], |
| 648 | + "platform": identity.get("platform"), |
| 649 | + "value": identity["value"], |
| 650 | + "verified": identity.get("verified", True), |
| 651 | + "member_id": member_id, |
| 652 | + } |
| 653 | + ) |
| 654 | + |
| 655 | + return results |
| 656 | + |
| 657 | + |
| 658 | +async def find_many_organization_ids_by_identities(identities: list[dict]) -> list[dict]: |
| 659 | + if not identities: |
| 660 | + return [] |
| 661 | + |
| 662 | + values_parts: list[str] = [] |
| 663 | + params: list[str | bool | int] = [] |
| 664 | + param_index = 1 |
| 665 | + for idx, identity in enumerate(identities): |
| 666 | + values_parts.append(f"(${param_index}, ${param_index + 1}, ${param_index + 2}, ${param_index + 3})") |
| 667 | + params.extend( |
| 668 | + [ |
| 669 | + idx, |
| 670 | + identity["type"], |
| 671 | + identity.get("verified", True), |
| 672 | + identity["value"], |
| 673 | + ] |
| 674 | + ) |
| 675 | + param_index += 4 |
| 676 | + |
| 677 | + matches_by_idx: dict[int, set[str]] = {} |
| 678 | + rows = await query( |
| 679 | + f""" |
| 680 | + WITH input_identities (idx, identity_type, verified, value) AS ( |
| 681 | + VALUES {", ".join(values_parts)} |
| 682 | + ) |
| 683 | + SELECT i.idx, oi."organizationId" |
| 684 | + FROM input_identities i |
| 685 | + LEFT JOIN "organizationIdentities" oi |
| 686 | + ON oi.type = i.identity_type |
| 687 | + AND oi.verified = i.verified |
| 688 | + AND lower(oi.value) = lower(i.value) |
| 689 | + ORDER BY i.idx |
| 690 | + """, |
| 691 | + tuple(params), |
| 692 | + ) |
| 693 | + for row in rows: |
| 694 | + if row["organizationId"] is None: |
| 695 | + continue |
| 696 | + matches_by_idx.setdefault(row["idx"], set()).add(str(row["organizationId"])) |
| 697 | + |
| 698 | + results: list[dict] = [] |
| 699 | + for idx, identity in enumerate(identities): |
| 700 | + organization_ids = matches_by_idx.get(idx, set()) |
| 701 | + organization_id = next(iter(organization_ids)) if len(organization_ids) == 1 else None |
| 702 | + results.append( |
| 703 | + { |
| 704 | + "type": identity["type"], |
| 705 | + "value": identity["value"], |
| 706 | + "verified": identity.get("verified", True), |
| 707 | + "organization_id": organization_id, |
| 708 | + } |
| 709 | + ) |
| 710 | + |
| 711 | + return results |
| 712 | + |
| 713 | + |
| 714 | +async def fetch_member_organizations(member_ids: list[str]) -> list[dict]: |
| 715 | + if not member_ids: |
| 716 | + return [] |
| 717 | + |
| 718 | + return await query( |
| 719 | + """ |
| 720 | + SELECT "memberId", "organizationId", "dateStart", "dateEnd", source |
| 721 | + FROM "memberOrganizations" |
| 722 | + WHERE "memberId" = ANY($1::uuid[]) |
| 723 | + AND "deletedAt" IS NULL |
| 724 | + """, |
| 725 | + (member_ids,), |
| 726 | + ) |
| 727 | + |
| 728 | + |
| 729 | +async def fetch_segment_affiliations(member_ids: list[str], segment_id: str) -> list[dict]: |
| 730 | + """MSA rows are per segment — filter by segment_id so guards match this repo's project.""" |
| 731 | + if not member_ids: |
| 732 | + return [] |
| 733 | + |
| 734 | + return await query( |
| 735 | + """ |
| 736 | + SELECT "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified |
| 737 | + FROM "memberSegmentAffiliations" |
| 738 | + WHERE "memberId" = ANY($1::uuid[]) |
| 739 | + AND "segmentId" = $2::uuid |
| 740 | + AND "deletedAt" IS NULL |
| 741 | + AND "organizationId" IS NOT NULL |
| 742 | + """, |
| 743 | + (member_ids, segment_id), |
| 744 | + ) |
| 745 | + |
| 746 | + |
| 747 | +async def insert_member_organizations(rows: list[dict]) -> int: |
| 748 | + if not rows: |
| 749 | + return 0 |
| 750 | + |
| 751 | + sql_query = """ |
| 752 | + INSERT INTO "memberOrganizations"( |
| 753 | + "memberId", |
| 754 | + "organizationId", |
| 755 | + "dateStart", |
| 756 | + "dateEnd", |
| 757 | + "title", |
| 758 | + source, |
| 759 | + verified, |
| 760 | + "createdAt", |
| 761 | + "updatedAt" |
| 762 | + ) |
| 763 | + VALUES ($1, $2, NULL, NULL, NULL, $3, false, NOW(), NOW()) |
| 764 | + ON CONFLICT ("memberId", "organizationId", "dateStart", "dateEnd") DO NOTHING |
| 765 | + """ |
| 766 | + await executemany( |
| 767 | + sql_query, |
| 768 | + [ |
| 769 | + ( |
| 770 | + row["member_id"], |
| 771 | + row["organization_id"], |
| 772 | + row.get("source", "project-registry"), |
| 773 | + ) |
| 774 | + for row in rows |
| 775 | + ], |
| 776 | + ) |
| 777 | + return len(rows) |
| 778 | + |
| 779 | + |
| 780 | +async def insert_member_segment_affiliations(rows: list[dict]) -> int: |
| 781 | + if not rows: |
| 782 | + return 0 |
| 783 | + |
| 784 | + sql_query = """ |
| 785 | + INSERT INTO "memberSegmentAffiliations"( |
| 786 | + id, |
| 787 | + "memberId", |
| 788 | + "segmentId", |
| 789 | + "organizationId", |
| 790 | + "dateStart", |
| 791 | + "dateEnd", |
| 792 | + verified |
| 793 | + ) |
| 794 | + VALUES (gen_random_uuid(), $1, $2, $3, NULL, NULL, $4) |
| 795 | + """ |
| 796 | + await executemany( |
| 797 | + sql_query, |
| 798 | + [ |
| 799 | + ( |
| 800 | + row["member_id"], |
| 801 | + row["segment_id"], |
| 802 | + row["organization_id"], |
| 803 | + row.get("verified", False), |
| 804 | + ) |
| 805 | + for row in rows |
| 806 | + ], |
| 807 | + ) |
| 808 | + return len(rows) |
| 809 | + |
0 commit comments