Skip to content

Commit 7f136af

Browse files
committed
feat: discover and parse project-maintained affiliations
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 57bea95 commit 7f136af

13 files changed

Lines changed: 1239 additions & 1 deletion

File tree

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

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from datetime import datetime, timezone
22

33
from loguru import logger
4+
from pydantic import TypeAdapter
45
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
56

67
from crowdgit.enums import RepositoryPriority, RepositoryState
78
from crowdgit.errors import RepoLockingError
9+
from crowdgit.models.affiliation_info import AffiliationInfoItem
810
from crowdgit.models.repository import Repository
911
from crowdgit.models.service_execution import ServiceExecution
1012
from crowdgit.settings import (
@@ -524,3 +526,284 @@ async def save_service_execution(service_execution: ServiceExecution) -> None:
524526
f"error: {e}"
525527
)
526528
# 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+

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

Lines changed: 10 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,18 @@ class ExecutionStatus(str, Enum):
6770
FAILURE = "failure"
6871

6972

73+
class AffiliationRegistryStatus(str, Enum):
74+
SUCCESS = "success"
75+
NOT_FOUND = "not_found"
76+
ERROR = "error"
77+
78+
7079
class OperationType(str, Enum):
7180
"""Service operation types for metrics tracking"""
7281

7382
CLONE = "Clone"
7483
COMMIT = "Commit"
7584
MAINTAINER = "Maintainer"
85+
REPO_AFFILIATION = "RepoAffiliation"
7686
SOFTWARE_VALUE = "SoftwareValue"
7787
VULNERABILITY_SCAN = "VulnerabilityScanner"

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ 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: int = 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+
119+
120+
@dataclass
121+
class AffiliationIntervalNotElapsedError(CrowdGitError):
122+
error_message: str = "Too soon since the last affiliation run"
123+
error_code: ErrorCode = ErrorCode.AFFILIATION_INTERVAL_NOT_ELAPSED
124+
ai_cost: int = 0
125+
126+
107127
@dataclass
108128
class ParentRepoInvalidError(CrowdGitError):
109129
error_message: str = "Parent repository is not valid or not found"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pydantic import BaseModel
2+
3+
4+
class AffiliationContributor(BaseModel):
5+
email: str | None = None
6+
name: str | None = None
7+
github: str | None = None
8+
9+
10+
class AffiliationOrganization(BaseModel):
11+
name: str | None = None
12+
domain: str | None = None
13+
14+
15+
class AffiliationInfoItem(BaseModel):
16+
contributor: AffiliationContributor
17+
organization: AffiliationOrganization
18+
19+
20+
class AffiliationFile(BaseModel):
21+
file_name: str | None = None
22+
error: str | None = None
23+
24+
25+
class AffiliationParseOutput(BaseModel):
26+
affiliations: list[AffiliationInfoItem] | None = None
27+
error: str | None = None

0 commit comments

Comments
 (0)