From 12d1a6cb88122676d6f91bde76fce4c861cacacd Mon Sep 17 00:00:00 2001 From: Yosoyepa Date: Thu, 27 Nov 2025 02:23:44 -0500 Subject: [PATCH 1/3] feat(database): implement ORM models and Alembic config for Supabase - Add UserEntity with Clerk ID, rate limiting, and role management - Add CodeReviewEntity with encrypted code storage support - Add AgentFindingEntity with severity and quality metrics - Create enums: SeverityEnum, UserRole aligned with PostgreSQL ENUMs - Configure Alembic env.py for model discovery and autogenerate - Implement relationships with cascade delete for data integrity Related CGAI-15 --- backend/alembic/env.py | 120 ++++++++++++++++++++++ backend/src/models/__init__.py | 28 +++++ backend/src/models/base.py | 5 +- backend/src/models/code_review.py | 65 +++++++++++- backend/src/models/enums/severity_enum.py | 22 ++++ backend/src/models/enums/user_role.py | 18 ++++ backend/src/models/finding.py | 106 +++++++++++++++++++ backend/src/models/user.py | 86 ++++++++++++++++ 8 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 backend/src/models/enums/user_role.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index e69de29..e9ee08c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -0,0 +1,120 @@ +""" +Alembic Environment Configuration for CodeGuard AI. + +Este archivo configura Alembic para detectar todos los modelos ORM +y generar migraciones automáticamente contra Supabase PostgreSQL. +""" + +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# ------------------------------------------------------------------------ +# 1. Configuración de Rutas (Path) +# ------------------------------------------------------------------------ +# Agregamos el directorio padre (backend/) al path de Python +# para que Alembic pueda encontrar la carpeta 'src' +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# ------------------------------------------------------------------------ +# 2. Importación de Modelos y Configuración +# ------------------------------------------------------------------------ +# Importamos la Base declarativa y TODOS los modelos +# IMPORTANTE: Cada modelo debe ser importado para que Alembic lo detecte +from src.models import ( + Base, + UserEntity, + CodeReviewEntity, + AgentFindingEntity, + ReviewStatus, + SeverityEnum, + UserRole, +) + +# Configuración de la base de datos +# Intentamos cargar desde settings, con fallback a variables de entorno +try: + from src.core.config.settings import settings + db_url = settings.DATABASE_URL +except ImportError: + from dotenv import load_dotenv + load_dotenv() + db_url = os.getenv("DATABASE_URL") + +if not db_url: + raise ValueError( + "DATABASE_URL no está configurada. " + "Configúrala en .env o en src/core/config/settings.py" + ) + +# ------------------------------------------------------------------------ +# 3. Configuración de Alembic +# ------------------------------------------------------------------------ +config = context.config + +# Interpretar el archivo de configuración para el logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Asignar la metadata de los modelos para que Alembic pueda "ver" las tablas +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """ + Ejecuta migraciones en modo 'offline'. + + Configura el contexto con solo una URL, sin crear un Engine. + Útil para generar scripts SQL sin conexión a la BD. + """ + context.configure( + url=db_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + # Comparar tipos para detectar cambios en columnas + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """ + Ejecuta migraciones en modo 'online'. + + Crea un Engine y se conecta a la base de datos Supabase. + """ + # Obtenemos la configuración de alembic.ini + configuration = config.get_section(config.config_ini_section) + + # Inyectar la URL de la base de datos desde el entorno + configuration["sqlalchemy.url"] = db_url + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + # Comparar tipos para detectar cambios en columnas + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index e69de29..b91a00a 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -0,0 +1,28 @@ +""" +SQLAlchemy ORM Models para CodeGuard AI. + +Este módulo exporta todas las entidades de base de datos para facilitar imports. +""" + +from src.models.base import Base +from src.models.code_review import CodeReviewEntity + +# Enums +from src.models.enums.review_status import ReviewStatus +from src.models.enums.severity_enum import SeverityEnum +from src.models.enums.user_role import UserRole +from src.models.finding import AgentFindingEntity +from src.models.user import UserEntity + +__all__ = [ + # Base + "Base", + # Entities + "UserEntity", + "CodeReviewEntity", + "AgentFindingEntity", + # Enums + "ReviewStatus", + "SeverityEnum", + "UserRole", +] diff --git a/backend/src/models/base.py b/backend/src/models/base.py index a4bfede..cc1270d 100644 --- a/backend/src/models/base.py +++ b/backend/src/models/base.py @@ -17,6 +17,9 @@ class Base(DeclarativeBase): comparado con la función antigua `declarative_base()`. Todas las entidades (ej. `CodeReviewEntity`) deben heredar de esta clase. + + __allow_unmapped__ = True permite usar anotaciones de tipo sin Mapped[] + para mantener compatibilidad con el código existente. """ - pass + __allow_unmapped__ = True diff --git a/backend/src/models/code_review.py b/backend/src/models/code_review.py index 764c55b..42d7f7b 100644 --- a/backend/src/models/code_review.py +++ b/backend/src/models/code_review.py @@ -1,29 +1,84 @@ +""" +Entidad ORM para code reviews. +Alineado con tabla 'code_reviews' en PostgreSQL (Supabase). +""" + import uuid from datetime import datetime +from typing import TYPE_CHECKING, List -from sqlalchemy import Column, DateTime, Enum, Integer, LargeBinary, String +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, LargeBinary, String, Text from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from src.models.base import Base from src.models.enums.review_status import ReviewStatus +if TYPE_CHECKING: + from src.models.finding import AgentFindingEntity + from src.models.user import UserEntity + class CodeReviewEntity(Base): """ Entidad ORM que representa la tabla 'code_reviews' en la base de datos. + + Attributes: + id: UUID del análisis + user_id: FK a users (Clerk user_id) + filename: Nombre del archivo analizado + code_content: Contenido encriptado con AES-256 (BYTEA) + quality_score: Puntuación de calidad (0-100) + status: PENDING, PROCESSING, COMPLETED, FAILED + total_findings: Número total de hallazgos + error_message: Mensaje de error si falló + created_at: Timestamp de creación + completed_at: Timestamp de finalización """ __tablename__ = "code_reviews" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(String, nullable=False, index=True) - filename = Column(String, nullable=False) + user_id = Column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + filename = Column(String(500), nullable=False) # RN16: code_content se almacena como bytes encriptados (BYTEA) code_content = Column(LargeBinary, nullable=False) - quality_score = Column(Integer, nullable=False) - status = Column(Enum(ReviewStatus), nullable=False, index=True) + quality_score = Column(Integer, nullable=True) + status = Column(Enum(ReviewStatus), default=ReviewStatus.PENDING, nullable=False, index=True) total_findings = Column(Integer, default=0) + error_message = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, index=True) completed_at = Column(DateTime, nullable=True) + + # Relationships + user: "UserEntity" = relationship("UserEntity", back_populates="code_reviews") + + findings: List["AgentFindingEntity"] = relationship( + "AgentFindingEntity", + back_populates="code_review", + cascade="all, delete-orphan", + lazy="dynamic", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + def calculate_quality_score(self) -> int: + """ + Calcula el quality score basado en los findings. + + Formula: score = max(0, 100 - sum(penalties)) + """ + + total_penalty = sum(f.penalty for f in self.findings) + return max(0, 100 - total_penalty) diff --git a/backend/src/models/enums/severity_enum.py b/backend/src/models/enums/severity_enum.py index e69de29..1447158 100644 --- a/backend/src/models/enums/severity_enum.py +++ b/backend/src/models/enums/severity_enum.py @@ -0,0 +1,22 @@ +""" +Enum para niveles de severidad de hallazgos. +Alineado con PostgreSQL ENUM 'finding_severity'. +""" + +from enum import Enum + + +class SeverityEnum(str, Enum): + """ + Niveles de severidad de un hallazgo en la base de datos. + + CRITICAL: OWASP Top 10, explotable inmediatamente + HIGH: Vulnerabilidades comunes que requieren condiciones específicas + MEDIUM: Code smells de seguridad/rendimiento + LOW: Violaciones de estilo menores + """ + + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" diff --git a/backend/src/models/enums/user_role.py b/backend/src/models/enums/user_role.py new file mode 100644 index 0000000..99751c3 --- /dev/null +++ b/backend/src/models/enums/user_role.py @@ -0,0 +1,18 @@ +""" +Enum para roles de usuario. +Alineado con PostgreSQL ENUM 'user_role'. +""" + +from enum import Enum + + +class UserRole(str, Enum): + """ + Roles de usuario en el sistema. + + DEVELOPER: Acceso básico, límite de 10 análisis/día + ADMIN: Acceso completo, sin límites, puede configurar agentes + """ + + DEVELOPER = "DEVELOPER" + ADMIN = "ADMIN" diff --git a/backend/src/models/finding.py b/backend/src/models/finding.py index e69de29..5043348 100644 --- a/backend/src/models/finding.py +++ b/backend/src/models/finding.py @@ -0,0 +1,106 @@ +""" +Entidad ORM para hallazgos de agentes. +Alineado con tabla 'agent_findings' en PostgreSQL (Supabase). +""" + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID +from sqlalchemy.orm import relationship + +from src.models.base import Base +from src.models.enums.severity_enum import SeverityEnum + +if TYPE_CHECKING: + from src.models.code_review import CodeReviewEntity + + +class AgentFindingEntity(Base): + """ + Entidad ORM que representa la tabla 'agent_findings' en la base de datos. + + Attributes: + id: UUID del hallazgo + review_id: FK a code_reviews + agent_type: Nombre del agente (SecurityAgent, QualityAgent, etc.) + severity: CRITICAL, HIGH, MEDIUM, LOW + issue_type: Tipo de problema (dangerous_function, sql_injection, etc.) + line_number: Número de línea donde se encontró + code_snippet: Fragmento de código problemático + message: Descripción del problema + suggestion: Sugerencia de corrección + metrics: Métricas adicionales (JSONB) + ai_explanation: Explicación generada por IA - Sprint 3 (JSONB) + mcp_references: Referencias a servidores MCP - Sprint 3 (TEXT[]) + created_at: Timestamp de creación + """ + + __tablename__ = "agent_findings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + review_id = Column( + UUID(as_uuid=True), + ForeignKey("code_reviews.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + agent_type = Column(String(100), nullable=False, index=True) + severity = Column(Enum(SeverityEnum), nullable=False, index=True) + issue_type = Column(String(200), nullable=False) + line_number = Column(Integer, nullable=False) + code_snippet = Column(Text, nullable=True) + message = Column(Text, nullable=False) + suggestion = Column(Text, nullable=True) + + # Campos adicionales + metrics = Column(JSONB, nullable=True) + + # Sprint 3: IA y MCP + ai_explanation = Column(JSONB, nullable=True) + mcp_references = Column(ARRAY(Text), nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + code_review: "CodeReviewEntity" = relationship("CodeReviewEntity", back_populates="findings") + + def __repr__(self) -> str: + return ( + f"" + ) + + @property + def penalty(self) -> int: + """Retorna la penalización para el quality score según severidad.""" + penalties = { + SeverityEnum.CRITICAL: 10, + SeverityEnum.HIGH: 5, + SeverityEnum.MEDIUM: 2, + SeverityEnum.LOW: 1, + } + return penalties.get(self.severity, 0) + + def to_dict(self) -> Dict[str, Any]: + """Convierte la entidad a diccionario.""" + return { + "id": str(self.id), + "review_id": str(self.review_id), + "agent_type": self.agent_type, + "severity": self.severity.value if self.severity else None, + "issue_type": self.issue_type, + "line_number": self.line_number, + "code_snippet": self.code_snippet, + "message": self.message, + "suggestion": self.suggestion, + "metrics": self.metrics, + "created_at": self.created_at.isoformat() if self.created_at else None, + } diff --git a/backend/src/models/user.py b/backend/src/models/user.py index e69de29..9db0232 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -0,0 +1,86 @@ +""" +Entidad ORM para usuarios. +Alineado con tabla 'users' en PostgreSQL (Supabase). +""" + +from datetime import date, datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import Column, Date, DateTime, Enum, Integer, String +from sqlalchemy.orm import relationship + +from src.models.base import Base +from src.models.enums.user_role import UserRole + +if TYPE_CHECKING: + from src.models.code_review import CodeReviewEntity + + +class UserEntity(Base): + """ + Entidad ORM que representa la tabla 'users' en la base de datos. + + Attributes: + id: Clerk user_id (VARCHAR, PK) + email: Email único del usuario + name: Nombre del usuario (opcional) + avatar_url: URL del avatar (opcional) + role: DEVELOPER o ADMIN + daily_analysis_count: Contador de análisis del día + last_analysis_date: Fecha del último análisis + created_at: Timestamp de creación + updated_at: Timestamp de última actualización + """ + + __tablename__ = "users" + + # Clerk user_id como PK (no es UUID, es string de Clerk) + id = Column(String(255), primary_key=True) + email = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=True) + avatar_url = Column(String(500), nullable=True) + role = Column(Enum(UserRole), default=UserRole.DEVELOPER, nullable=False, index=True) + + # Rate limiting (RN3: 10 análisis/día para developers) + daily_analysis_count = Column(Integer, default=0, nullable=False) + last_analysis_date = Column(Date, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + code_reviews: List["CodeReviewEntity"] = relationship( + "CodeReviewEntity", back_populates="user", cascade="all, delete-orphan", lazy="dynamic" + ) + + def __repr__(self) -> str: + return f"" + + def can_analyze(self, max_daily: int = 10) -> bool: + """ + Verifica si el usuario puede realizar más análisis hoy. + + Args: + max_daily: Límite diario para developers (default: 10) + + Returns: + True si puede analizar, False si alcanzó el límite + """ + if self.role == UserRole.ADMIN: + return True + + today = date.today() + if self.last_analysis_date != today: + return True + + return self.daily_analysis_count < max_daily + + def increment_analysis_count(self) -> None: + """Incrementa el contador de análisis del día.""" + today = date.today() + if self.last_analysis_date != today: + self.daily_analysis_count = 1 + self.last_analysis_date = today + else: + self.daily_analysis_count += 1 From 6d9b6fc1f88bbb2b42d465db097328506f8a2ab7 Mon Sep 17 00:00:00 2001 From: Yosoyepa Date: Thu, 27 Nov 2025 02:50:49 -0500 Subject: [PATCH 2/3] feat(database): add Alembic migration for initial tables - Create users table with Clerk ID, role, rate limiting fields - Create code_reviews table with encrypted code storage - Create agent_findings table with severity and metrics - Add PostgreSQL ENUMs: userrole, reviewstatus, severityenum - Add indexes for performance optimization - Configure cascade delete for data integrity Migration ID: ba48c1bb8e18 Related CGAI-15 --- backend/alembic/script.py.mako | 33 +++++++ ...b8e18_create_initial_tables_users_code_.py | 97 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index e69de29..04d5198 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -0,0 +1,33 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + + + + + + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py b/backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py new file mode 100644 index 0000000..00d9d72 --- /dev/null +++ b/backend/alembic/versions/ba48c1bb8e18_create_initial_tables_users_code_.py @@ -0,0 +1,97 @@ +"""create_initial_tables_users_code_reviews_findings + +Revision ID: ba48c1bb8e18 +Revises: +Create Date: 2025-11-27 02:43:54.598631 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'ba48c1bb8e18' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('avatar_url', sa.String(length=500), nullable=True), + sa.Column('role', sa.Enum('DEVELOPER', 'ADMIN', name='userrole'), nullable=False), + sa.Column('daily_analysis_count', sa.Integer(), nullable=False), + sa.Column('last_analysis_date', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False) + op.create_table('code_reviews', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.String(length=255), nullable=False), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('code_content', sa.LargeBinary(), nullable=False), + sa.Column('quality_score', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', name='reviewstatus'), nullable=False), + sa.Column('total_findings', sa.Integer(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_code_reviews_created_at'), 'code_reviews', ['created_at'], unique=False) + op.create_index(op.f('ix_code_reviews_status'), 'code_reviews', ['status'], unique=False) + op.create_index(op.f('ix_code_reviews_user_id'), 'code_reviews', ['user_id'], unique=False) + op.create_table('agent_findings', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('review_id', sa.UUID(), nullable=False), + sa.Column('agent_type', sa.String(length=100), nullable=False), + sa.Column('severity', sa.Enum('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', name='severityenum'), nullable=False), + sa.Column('issue_type', sa.String(length=200), nullable=False), + sa.Column('line_number', sa.Integer(), nullable=False), + sa.Column('code_snippet', sa.Text(), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('suggestion', sa.Text(), nullable=True), + sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('ai_explanation', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('mcp_references', postgresql.ARRAY(sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['review_id'], ['code_reviews.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_agent_findings_agent_type'), 'agent_findings', ['agent_type'], unique=False) + op.create_index(op.f('ix_agent_findings_review_id'), 'agent_findings', ['review_id'], unique=False) + op.create_index(op.f('ix_agent_findings_severity'), 'agent_findings', ['severity'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + + + + + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_agent_findings_severity'), table_name='agent_findings') + op.drop_index(op.f('ix_agent_findings_review_id'), table_name='agent_findings') + op.drop_index(op.f('ix_agent_findings_agent_type'), table_name='agent_findings') + op.drop_table('agent_findings') + op.drop_index(op.f('ix_code_reviews_user_id'), table_name='code_reviews') + op.drop_index(op.f('ix_code_reviews_status'), table_name='code_reviews') + op.drop_index(op.f('ix_code_reviews_created_at'), table_name='code_reviews') + op.drop_table('code_reviews') + op.drop_index(op.f('ix_users_role'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### \ No newline at end of file From 520ecee1ca1fe101613a365eb6bf349398f5b16f Mon Sep 17 00:00:00 2001 From: Yosoyepa Date: Thu, 27 Nov 2025 03:29:29 -0500 Subject: [PATCH 3/3] docs(readme): add Supabase badge and Sprint 2 progress update --- README.md | Bin 49634 -> 50998 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/README.md b/README.md index f35071796c29f86bf80194ff8c672eaf87a59c4d..e35984767d74196b02bc571b13b9c0ad635d1844 100644 GIT binary patch delta 1062 zcmZ{j&uUXa6vn?&6)T8BDXG#$z&}MXg_xwNMG3X0&|+Fl4N^p!qov>{D#{GwWk+aSDozd2ScI$ zmCD)=62#V%X_mAbKh7xeIqxB16d`j@rUv zmsD7n^+*pj1JumkJ;z%CZQnJA>AvmpSsa_BtI<~Iz%I-&=Jmi`wLm#b zTGV}anxXJ4?X0*{8hegLYU)my<>Czp@yKf=`1m`7cuslyTKPtup?ya+gj~h+yH%~% z3awI}@n2@XvcO>L(@5}b>{Mf6Z1HXI%{p9*n=h={BHdn^^>m!LO~G>bO|UerSm||SZ%G$?pBOnf#ty4($>xaY2-b`3W;z3| z`83dt>@upP5{lzb5L)E%7B$H($}Ubi6ELDXj3m|wrx