Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=gitlab_db
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/gitlab_db
TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@db_test:5432/test_db
SECRET_KEY=insecure_secret_key_for_ci_and_dev
LLM_SERVICE_URL=http://llm-service:8001/api/v1/ask
MLOPS_SERVICE_URL=http://mlops-service:8002/api/v1/trigger_dag
ENCRYPTION_KEY="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Backend CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Create .env file
run: cp .env.example .env || touch .env

- name: Start containers
run: docker compose up -d --build --wait backend db db_test

- name: Run Tests
run: docker compose exec -T backend pytest -v

- name: Stop containers
if: always()
run: docker compose down -v
89 changes: 89 additions & 0 deletions alembic/versions/15a091196cf4_init_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""init_schema

Revision ID: 15a091196cf4
Revises:
Create Date: 2025-12-28 18:41:13.254645

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '15a091196cf4'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('gitlab_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.Column('private_token_encrypted', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('indexing_jobs',
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('repository_ids', sa.JSON(), nullable=False),
sa.Column('details', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_indexing_jobs_status'), 'indexing_jobs', ['status'], unique=False)
op.create_table('roles',
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('permissions', sa.JSON(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
op.create_table('users',
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('role', sa.String(length=50), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('chats',
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('messages',
sa.Column('chat_id', sa.UUID(), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('sources', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('messages')
op.drop_table('chats')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_roles_name'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_indexing_jobs_status'), table_name='indexing_jobs')
op.drop_table('indexing_jobs')
op.drop_table('gitlab_configs')
# ### end Alembic commands ###
19 changes: 19 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ services:
timeout: 5s
retries: 5

db_test:
image: postgres:15-alpine
container_name: gitlab-db-test
restart: always

environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mysecretpassword}
POSTGRES_DB: test_db

ports:
- "5433:5432"

healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d test_db"]
interval: 10s
timeout: 5s
retries: 5

backend:
container_name: gitlab-backend

Expand Down
19 changes: 19 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ dependencies = [
"python-jose[cryptography]>=3.3.0",
"bcrypt==4.0.1",
"cryptography>=42.0.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"greenlet>=3.2.4",
"pytest-mock>=3.15.1",
"dishka>=1.7.2",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"greenlet>=3.2.4",
"pytest-mock>=3.15.1",
"dishka>=1.7.2",
]

Expand All @@ -43,6 +54,7 @@ fixable = ["ALL"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
"alembic/versions/*" = ["ALL"]
"tests/*" = ["S101", "S105", "S106", "S107"]

[tool.ruff.lint.mccabe]
max-complexity = 10
Expand All @@ -51,7 +63,14 @@ max-complexity = 10
combine-as-imports = true
known-first-party = ["app"]

[tool.pytest.ini_options]
pythonpath = [
".", "src"
]
asyncio_mode = "auto"

[dependency-groups]
dev = [
"pytest-cov>=7.0.0",
"ruff>=0.14.1",
]
65 changes: 65 additions & 0 deletions src/api/routers/indexing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Depends, HTTPException, status

from src.api.dependencies import get_current_admin_user
from src.api.schemas.repository import (
IndexingJob,
JobStatusUpdate,
SyncRequest,
)
from src.application.services.index_service import IndexService

router_indexing = APIRouter(
dependencies=[Depends(get_current_admin_user)],
route_class=DishkaRoute
)


@router_indexing.post("/trigger", response_model=IndexingJob)
async def trigger_indexing(
sync_request: SyncRequest,
service: FromDishka[IndexService]
):
"""Start an indexing of repositories by their ids."""
return await service.trigger_indexing(
repository_ids=sync_request.repository_ids
)


@router_indexing.delete("/{job_id}")
async def delete_indexing_job(
job_id: str,
service: FromDishka[IndexService]
):
"""Delete an existing job by its id.

Return true if deleted, false if the job doesn't exist.
"""
return await service.delete_indexind_job(job_id)


@router_indexing.get("/status/{job_id}", response_model=IndexingJob)
async def get_indexing_status(
job_id: str,
service: FromDishka[IndexService]
):
"""Get status of indexing job by its id."""
return await service.get_indexing_status(job_id)


@router_indexing.put("/status/{job_id}", response_model=IndexingJob)
async def update_indexing_status(
job_id: str,
status_update: JobStatusUpdate,
service: FromDishka[IndexService]
):
"""Update a status of an existing job by its id."""
updated_job = await service.update_indexing_status(job_id, status_update)
if not updated_job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job with id {job_id} not found"
)

return updated_job
51 changes: 1 addition & 50 deletions src/api/routers/repository.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
from typing import List

from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, status

from src.api.dependencies import get_current_admin_user
from src.api.schemas.repository import (
GitLabConfigCreate,
IndexingJob,
JobStatusUpdate,
Repository,
SyncRequest,
)
from src.application.services.index_service import IndexService

Expand Down Expand Up @@ -38,49 +35,3 @@ async def list_gitlab_repositories(
"""Get list of repositories that are available for indexing."""
return await service.list_repositories()


@router_repository.post("/trigger", response_model=IndexingJob)
async def trigger_indexing(
sync_request: SyncRequest,
service: FromDishka[IndexService]
):
"""Start an indexing of repositories by their ids."""
return await service.trigger_indexing(
repository_ids=sync_request.repository_ids
)

@router_repository.delete("/{job_id}")
async def delete_indexing_job(
job_id: str,
service: FromDishka[IndexService]
):
"""Delete an existing job by its id.

Return true if deleted, false if the job doesn't exist.
"""
return await service.delete_indexind_job(job_id)

@router_repository.get("/status/{job_id}", response_model=IndexingJob)
async def get_indexing_status(
job_id: str,
service: FromDishka[IndexService]
):
"""Get status of indexing job by its id."""
return await service.get_indexing_status(job_id)


@router_repository.put("/status/{job_id}", response_model=IndexingJob)
async def update_indexing_status(
job_id: str,
status_update: JobStatusUpdate,
service: FromDishka[IndexService]
):
"""Update a status of an existing job by its id."""
updated_job = await service.update_indexing_status(job_id, status_update)
if not updated_job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job with id {job_id} not found"
)

return updated_job
6 changes: 4 additions & 2 deletions src/application/services/admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ async def create_new_role(self, role_create: RoleCreate) -> DomainRole:
existing_role = await self.role_repo.get_by_name(role_create.name)
if existing_role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Role already exists"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role already exists"
)

new_role = DomainRole(
Expand All @@ -64,7 +65,8 @@ async def create_new_role(self, role_create: RoleCreate) -> DomainRole:
created_role = await self.role_repo.create(new_role)
if not created_role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Error creating a new role"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Error creating a new role"
)

return created_role
2 changes: 1 addition & 1 deletion src/application/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __init__(
):
self.chat_repo = chat_repo

async def create_chat(self, owner_id: UUID4, title: str):
async def create_chat(self, owner_id: UUID4, title: str) -> Chat:
"""Create a new chat with specified title for user by their id."""
return await self.chat_repo.create_chat(owner_id, title)

Expand Down
10 changes: 5 additions & 5 deletions src/application/services/index_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
from fastapi import HTTPException, status
from pydantic import UUID4

from src.api.schemas.repository import IndexingJob as IndexingJobSchema, JobStatusUpdate
from src.api.schemas.repository import JobStatusUpdate
from src.core.settings import settings
from src.domain.models.knowledge import Repository
from src.domain.models.knowledge import IndexingJob, Repository
from src.domain.repositories.gitlab_repo import IGitLabRepository
from src.domain.repositories.job_repo import IJobRepository
from src.infrastructure.external.gitlab_client import GitLabClient
Expand Down Expand Up @@ -52,7 +52,7 @@ async def list_repositories(self) -> List[Repository]:
token=raw_token
)

async def trigger_indexing(self, repository_ids: List[UUID4]) -> IndexingJobSchema:
async def trigger_indexing(self, repository_ids: List[UUID4]) -> IndexingJob:
"""Trigger and run indexing service."""
config = await self.gitlab_repo.get_config()
if not config:
Expand Down Expand Up @@ -89,14 +89,14 @@ async def delete_indexind_job(self, job_id: str) -> bool:

return {"status": "error", "message": f"Job {job_id} doesn't exist."}

async def get_indexing_status(self, job_id: str) -> Optional[IndexingJobSchema]:
async def get_indexing_status(self, job_id: str) -> Optional[IndexingJob]:
"""Get status for existing indexing job."""
return await self.job_repo.get_job(job_id)

async def update_indexing_status(
self,
job_id: str,
status_update: JobStatusUpdate
) -> Optional[IndexingJobSchema]:
) -> Optional[IndexingJob]:
"""Update a status of an existing job by its id."""
return await self.job_repo.update_job_status(job_id, status_update.status)
Loading
Loading