Skip to content

Commit 25f5f29

Browse files
authored
feat: discover and parse project-maintained affiliations (CM-361) (#4280)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 8565964 commit 25f5f29

13 files changed

Lines changed: 1450 additions & 1 deletion

File tree

services/apps/git_integration/src/crowdgit/database/crud.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from crowdgit.enums import RepositoryPriority, RepositoryState
77
from crowdgit.errors import RepoLockingError
8+
from crowdgit.models.affiliation_info import RepoAffiliationRegistry
89
from crowdgit.models.repository import Repository
910
from crowdgit.models.service_execution import ServiceExecution
1011
from crowdgit.settings import (
@@ -552,3 +553,303 @@ async def save_service_execution(service_execution: ServiceExecution) -> None:
552553
f"error: {e}"
553554
)
554555
# Do not re-raise - we don't want metrics saving to disrupt main operations
556+
557+
558+
async def get_repo_affiliation_registry(repo_id: str) -> RepoAffiliationRegistry | None:
559+
sql_query = """
560+
SELECT "repoId", "filePath", "fileHash", "status", "snapshot", "lastRunAt"
561+
FROM git."repoAffiliationRegistry"
562+
WHERE "repoId" = $1
563+
"""
564+
result = await fetchrow(sql_query, (repo_id,))
565+
if not result:
566+
return None
567+
568+
return RepoAffiliationRegistry.from_db(dict(result))
569+
570+
571+
async def upsert_repo_affiliation_registry(registry: RepoAffiliationRegistry) -> None:
572+
snapshot_json = registry.snapshot_for_db()
573+
sql_query = """
574+
INSERT INTO git."repoAffiliationRegistry" (
575+
"repoId", "filePath", "fileHash", "status", "snapshot", "lastRunAt", "updatedAt"
576+
)
577+
VALUES ($1, $2, $3, $4, $5::jsonb, NOW(), NOW())
578+
ON CONFLICT ("repoId") DO UPDATE SET
579+
"filePath" = EXCLUDED."filePath",
580+
"fileHash" = EXCLUDED."fileHash",
581+
"status" = EXCLUDED."status",
582+
"snapshot" = EXCLUDED."snapshot",
583+
"lastRunAt" = NOW(),
584+
"updatedAt" = NOW()
585+
"""
586+
await execute(
587+
sql_query,
588+
(
589+
registry.repo_id,
590+
registry.file_path,
591+
registry.file_hash,
592+
registry.status,
593+
snapshot_json,
594+
),
595+
)
596+
597+
598+
async def find_many_member_ids_by_identities(identities: list[dict]) -> list[dict]:
599+
if not identities:
600+
return []
601+
602+
values_parts: list[str] = []
603+
params: list[str | bool | int] = []
604+
param_index = 1
605+
for idx, identity in enumerate(identities):
606+
values_parts.append(
607+
f"(${param_index}::int, ${param_index + 1}::text, ${param_index + 2}::boolean,"
608+
f" ${param_index + 3}::text, ${param_index + 4}::text)"
609+
)
610+
params.extend(
611+
[
612+
idx,
613+
identity["type"],
614+
identity.get("verified", True),
615+
identity.get("platform"),
616+
identity["value"],
617+
]
618+
)
619+
param_index += 5
620+
621+
matches_by_idx: dict[int, set[str]] = {}
622+
rows = await query(
623+
f"""
624+
WITH input_identities (idx, identity_type, verified, platform, value) AS (
625+
VALUES {", ".join(values_parts)}
626+
)
627+
SELECT i.idx, mi."memberId"
628+
FROM input_identities i
629+
LEFT JOIN "memberIdentities" mi
630+
ON mi.type = i.identity_type
631+
AND mi.verified = i.verified
632+
AND lower(mi.value) = lower(i.value)
633+
AND mi.platform = i.platform
634+
AND mi."deletedAt" IS NULL
635+
ORDER BY i.idx
636+
""",
637+
tuple(params),
638+
)
639+
for row in rows:
640+
if row["memberId"] is None:
641+
continue
642+
matches_by_idx.setdefault(row["idx"], set()).add(str(row["memberId"]))
643+
644+
results: list[dict] = []
645+
for idx, identity in enumerate(identities):
646+
member_ids = matches_by_idx.get(idx, set())
647+
member_id = next(iter(member_ids)) if len(member_ids) == 1 else None
648+
results.append(
649+
{
650+
"type": identity["type"],
651+
"platform": identity.get("platform"),
652+
"value": identity["value"],
653+
"verified": identity.get("verified", True),
654+
"member_id": member_id,
655+
}
656+
)
657+
658+
return results
659+
660+
661+
async def find_many_organization_ids_by_identities(identities: list[dict]) -> list[dict]:
662+
if not identities:
663+
return []
664+
665+
values_parts: list[str] = []
666+
params: list[str | bool | int] = []
667+
param_index = 1
668+
for idx, identity in enumerate(identities):
669+
values_parts.append(
670+
f"(${param_index}::int, ${param_index + 1}::text, ${param_index + 2}::boolean,"
671+
f" ${param_index + 3}::text, ${param_index + 4}::text)"
672+
)
673+
params.extend(
674+
[
675+
idx,
676+
identity["type"],
677+
identity.get("verified", True),
678+
identity["platform"],
679+
identity["value"],
680+
]
681+
)
682+
param_index += 5
683+
684+
matches_by_idx: dict[int, set[str]] = {}
685+
rows = await query(
686+
f"""
687+
WITH input_identities (idx, identity_type, verified, platform, value) AS (
688+
VALUES {", ".join(values_parts)}
689+
)
690+
SELECT i.idx, oi."organizationId"
691+
FROM input_identities i
692+
LEFT JOIN "organizationIdentities" oi
693+
ON oi.type = i.identity_type
694+
AND oi.verified = i.verified
695+
AND oi.platform = i.platform
696+
AND lower(oi.value) = lower(i.value)
697+
ORDER BY i.idx
698+
""",
699+
tuple(params),
700+
)
701+
for row in rows:
702+
if row["organizationId"] is None:
703+
continue
704+
matches_by_idx.setdefault(row["idx"], set()).add(str(row["organizationId"]))
705+
706+
results: list[dict] = []
707+
for idx, identity in enumerate(identities):
708+
organization_ids = matches_by_idx.get(idx, set())
709+
organization_id = next(iter(organization_ids)) if len(organization_ids) == 1 else None
710+
results.append(
711+
{
712+
"type": identity["type"],
713+
"platform": identity["platform"],
714+
"value": identity["value"],
715+
"verified": identity.get("verified", True),
716+
"organization_id": organization_id,
717+
}
718+
)
719+
720+
return results
721+
722+
723+
async def fetch_member_organizations(member_ids: list[str]) -> list[dict]:
724+
if not member_ids:
725+
return []
726+
727+
return await query(
728+
"""
729+
SELECT "memberId", "organizationId", "dateStart", "dateEnd", source
730+
FROM "memberOrganizations"
731+
WHERE "memberId" = ANY($1::uuid[])
732+
AND "deletedAt" IS NULL
733+
""",
734+
(member_ids,),
735+
)
736+
737+
738+
async def fetch_segment_affiliations(member_ids: list[str], segment_id: str) -> list[dict]:
739+
"""MSA rows are per segment — filter by segment_id so guards match this repo's project."""
740+
if not member_ids:
741+
return []
742+
743+
return await query(
744+
"""
745+
SELECT "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified
746+
FROM "memberSegmentAffiliations"
747+
WHERE "memberId" = ANY($1::uuid[])
748+
AND "segmentId" = $2::uuid
749+
AND "deletedAt" IS NULL
750+
AND "organizationId" IS NOT NULL
751+
""",
752+
(member_ids, segment_id),
753+
)
754+
755+
756+
async def insert_member_organizations(rows: list[dict]) -> None:
757+
if not rows:
758+
return
759+
760+
undated_rows: list[tuple] = []
761+
open_ended_rows: list[tuple] = []
762+
dated_rows: list[tuple] = []
763+
764+
for row in rows:
765+
params = (
766+
row["member_id"],
767+
row["organization_id"],
768+
row.get("date_start"),
769+
row.get("date_end"),
770+
row["source"],
771+
)
772+
date_start = row.get("date_start")
773+
date_end = row.get("date_end")
774+
if date_start is None and date_end is None:
775+
undated_rows.append(params)
776+
elif date_end is None:
777+
open_ended_rows.append(params)
778+
else:
779+
dated_rows.append(params)
780+
781+
insert_sql = """
782+
INSERT INTO "memberOrganizations"(
783+
"memberId",
784+
"organizationId",
785+
"dateStart",
786+
"dateEnd",
787+
title,
788+
source,
789+
"createdAt",
790+
"updatedAt"
791+
)
792+
VALUES ($1, $2, $3, $4, NULL, $5, NOW(), NOW())
793+
"""
794+
795+
if undated_rows:
796+
sql = (
797+
insert_sql
798+
+ """
799+
ON CONFLICT ("memberId", "organizationId")
800+
WHERE ("dateStart" IS NULL AND "dateEnd" IS NULL AND "deletedAt" IS NULL)
801+
DO NOTHING
802+
"""
803+
)
804+
await executemany(sql, undated_rows)
805+
806+
if open_ended_rows:
807+
sql = (
808+
insert_sql
809+
+ """
810+
ON CONFLICT ("memberId", "organizationId", "dateStart")
811+
WHERE ("dateEnd" IS NULL AND "deletedAt" IS NULL)
812+
DO NOTHING
813+
"""
814+
)
815+
await executemany(sql, open_ended_rows)
816+
817+
if dated_rows:
818+
sql = (
819+
insert_sql
820+
+ """
821+
ON CONFLICT ("memberId", "organizationId", "dateStart", "dateEnd")
822+
WHERE ("deletedAt" IS NULL)
823+
DO NOTHING
824+
"""
825+
)
826+
await executemany(sql, dated_rows)
827+
828+
829+
async def insert_member_segment_affiliations(rows: list[dict]) -> None:
830+
if not rows:
831+
return
832+
833+
await executemany(
834+
"""
835+
INSERT INTO "memberSegmentAffiliations"(
836+
id,
837+
"memberId",
838+
"segmentId",
839+
"organizationId",
840+
"dateStart",
841+
"dateEnd"
842+
)
843+
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5)
844+
""",
845+
[
846+
(
847+
row["member_id"],
848+
row["segment_id"],
849+
row["organization_id"],
850+
row.get("date_start"),
851+
row.get("date_end"),
852+
)
853+
for row in rows
854+
],
855+
)

services/apps/git_integration/src/crowdgit/enums.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class ErrorCode(str, Enum):
1818
NO_MAINTAINER_FOUND = "no-maintainer-found"
1919
MAINTAINER_ANALYSIS_FAILED = "maintainer-analysis-failed"
2020
MAINTAINER_INTERVAL_NOT_ELAPSED = "maintainer-interval-not-elapsed"
21+
NO_AFFILIATION_FILE = "no-affiliation-file"
22+
AFFILIATION_ANALYSIS_FAILED = "affiliation-analysis-failed"
23+
AFFILIATION_INTERVAL_NOT_ELAPSED = "affiliation-interval-not-elapsed"
2124
CLEANUP_FAILED = "cleanup-failed"
2225
PARENT_REPO_INVALID = "parent-repo-invalid"
2326
REONBOARDING_REQUIRED = "reonboarding-required"
@@ -67,11 +70,19 @@ class ExecutionStatus(str, Enum):
6770
FAILURE = "failure"
6871

6972

73+
class AffiliationRegistryStatus(str, Enum):
74+
SUCCESS = "success"
75+
NOT_FOUND = "not_found"
76+
UNUSABLE = "unusable"
77+
ERROR = "error"
78+
79+
7080
class OperationType(str, Enum):
7181
"""Service operation types for metrics tracking"""
7282

7383
CLONE = "Clone"
7484
COMMIT = "Commit"
7585
MAINTAINER = "Maintainer"
86+
AFFILIATION = "Affiliation"
7687
SOFTWARE_VALUE = "SoftwareValue"
7788
VULNERABILITY_SCAN = "VulnerabilityScanner"

services/apps/git_integration/src/crowdgit/errors.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,27 @@ class MaintainerIntervalNotElapsedError(CrowdGitError):
104104
ai_cost: int = 0
105105

106106

107+
@dataclass
108+
class AffiliationFileNotFoundError(CrowdGitError):
109+
error_message: str = "No affiliation file found in this repository"
110+
error_code: ErrorCode = ErrorCode.NO_AFFILIATION_FILE
111+
ai_cost: float = 0.0
112+
113+
114+
@dataclass
115+
class AffiliationAnalysisError(CrowdGitError):
116+
error_message: str = "Could not parse the affiliation file"
117+
error_code: ErrorCode = ErrorCode.AFFILIATION_ANALYSIS_FAILED
118+
retain_file_hash: bool = False
119+
120+
121+
@dataclass
122+
class AffiliationIntervalNotElapsedError(CrowdGitError):
123+
error_message: str = "Too soon since the last affiliation run"
124+
error_code: ErrorCode = ErrorCode.AFFILIATION_INTERVAL_NOT_ELAPSED
125+
ai_cost: float = 0.0
126+
127+
107128
@dataclass
108129
class ParentRepoInvalidError(CrowdGitError):
109130
error_message: str = "Parent repository is not valid or not found"

0 commit comments

Comments
 (0)