Skip to content

Commit ac5dd0b

Browse files
Merge pull request #322 from bg-playground/copilot/add-projects-api
Add Projects API and enforce `project_id` existence for External Results sessions
2 parents 01a22fc + f56af4f commit ac5dd0b

17 files changed

Lines changed: 631 additions & 44 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""add projects table
2+
3+
Revision ID: n3o4p5q6r7s8
4+
Revises: m2n3o4p5q6r7
5+
Create Date: 2026-05-08 19:10:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import postgresql
13+
14+
from alembic import op
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "n3o4p5q6r7s8"
18+
down_revision: Union[str, None] = "m2n3o4p5q6r7"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.create_table(
25+
"projects",
26+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
27+
sa.Column("name", sa.String(length=255), nullable=False),
28+
sa.Column("description", sa.Text(), nullable=True),
29+
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
30+
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
31+
sa.PrimaryKeyConstraint("id"),
32+
)
33+
op.create_index("ix_projects_name", "projects", ["name"], unique=False)
34+
35+
36+
def downgrade() -> None:
37+
op.drop_index("ix_projects_name", table_name="projects")
38+
op.drop_table("projects")

backend/app/api/external_results.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ async def create_external_session(
145145
normalized_payload = payload.model_copy(
146146
update={"runner": payload.runner if payload.runner is not None else _DEFAULT_RUNNER}
147147
)
148-
session = await create_session(db, payload=normalized_payload, runner_token_id=token.id)
148+
try:
149+
session = await create_session(db, payload=normalized_payload, runner_token_id=token.id)
150+
except ValueError as exc:
151+
detail = exc.args[0]
152+
if isinstance(detail, dict) and detail.get("code") == "session.project_not_found":
153+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
154+
raise
149155
await write_audit(
150156
db,
151157
actor_kind="runner_token",

backend/app/api/projects.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""API endpoints for Projects."""
2+
3+
import math
4+
from typing import Any
5+
from uuid import UUID
6+
7+
from fastapi import APIRouter, Depends, HTTPException, Query, status
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from app.auth.dependencies import get_current_user, require_reviewer_or_admin
11+
from app.crud import project as crud
12+
from app.crud.audit_log import write_audit
13+
from app.db.session import get_db
14+
from app.models.user import User
15+
from app.schemas.pagination import PaginatedResponse
16+
from app.schemas.project import ProjectCreate, ProjectResponse, ProjectUpdate
17+
18+
router = APIRouter()
19+
20+
21+
@router.post("/projects", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
22+
async def create_project(
23+
payload: ProjectCreate,
24+
db: AsyncSession = Depends(get_db),
25+
current_user: User = Depends(require_reviewer_or_admin),
26+
) -> ProjectResponse:
27+
project = await crud.create_project(db, payload)
28+
await write_audit(
29+
db,
30+
actor_kind="user",
31+
actor_id=current_user.id,
32+
action="project.create",
33+
resource_type="project",
34+
resource_id=project.id,
35+
details=payload.model_dump(),
36+
)
37+
return project
38+
39+
40+
@router.get("/projects", response_model=PaginatedResponse[ProjectResponse])
41+
async def list_projects(
42+
page: int = Query(1, ge=1),
43+
page_size: int = Query(50, ge=1, le=200),
44+
db: AsyncSession = Depends(get_db),
45+
_current_user: User = Depends(get_current_user),
46+
) -> PaginatedResponse[ProjectResponse]:
47+
skip = (page - 1) * page_size
48+
items, total = await crud.list_projects(db, skip=skip, limit=page_size)
49+
return PaginatedResponse(
50+
items=items,
51+
total=total,
52+
page=page,
53+
page_size=page_size,
54+
pages=math.ceil(total / page_size) if total > 0 else 0,
55+
)
56+
57+
58+
@router.get("/projects/{project_id}", response_model=ProjectResponse)
59+
async def get_project(
60+
project_id: UUID,
61+
db: AsyncSession = Depends(get_db),
62+
_current_user: User = Depends(get_current_user),
63+
) -> ProjectResponse:
64+
project = await crud.get_project(db, project_id)
65+
if project is None:
66+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found")
67+
return project
68+
69+
70+
@router.patch("/projects/{project_id}", response_model=ProjectResponse)
71+
async def update_project(
72+
project_id: UUID,
73+
payload: ProjectUpdate,
74+
db: AsyncSession = Depends(get_db),
75+
current_user: User = Depends(require_reviewer_or_admin),
76+
) -> ProjectResponse:
77+
existing = await crud.get_project(db, project_id)
78+
if existing is None:
79+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found")
80+
81+
changed_fields = payload.model_dump(exclude_unset=True)
82+
original_values = {field: getattr(existing, field) for field in changed_fields}
83+
updated = await crud.update_project(db, project_id, payload)
84+
if updated is None:
85+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Project {project_id} not found")
86+
87+
diff: dict[str, dict[str, Any]] = {}
88+
for field, new_value in changed_fields.items():
89+
old_value = original_values[field]
90+
if old_value != new_value:
91+
diff[field] = {"from": old_value, "to": new_value}
92+
93+
await write_audit(
94+
db,
95+
actor_kind="user",
96+
actor_id=current_user.id,
97+
action="project.update",
98+
resource_type="project",
99+
resource_id=updated.id,
100+
details=diff,
101+
)
102+
return updated

backend/app/crud/external_results.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sqlalchemy.ext.asyncio import AsyncSession
88

99
from app.models.external_results import ExternalRunSession, RunStatus
10+
from app.models.project import Project
1011
from app.schemas.external_results import SessionCreate, SessionFinish
1112

1213
# Terminal statuses — no further transitions allowed once reached.
@@ -31,6 +32,17 @@ async def create_session(
3132
of creating a duplicate.
3233
3334
"""
35+
project_result = await db.execute(select(Project.id).where(Project.id == payload.project_id))
36+
project_id = project_result.scalar_one_or_none()
37+
if project_id is None:
38+
raise ValueError(
39+
{
40+
"code": "session.project_not_found",
41+
"message": f"Project {payload.project_id} does not exist.",
42+
"details": None,
43+
}
44+
)
45+
3446
cutoff = datetime.now(tz=timezone.utc).replace(tzinfo=None) - timedelta(seconds=_IDEMPOTENCY_WINDOW_SECONDS)
3547

3648
# Normalise ci_url to a plain string so we can compare it.

backend/app/crud/project.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""CRUD operations for Projects."""
2+
3+
from uuid import UUID
4+
5+
from sqlalchemy import func, select
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
from app.models.project import Project
9+
from app.schemas.project import ProjectCreate, ProjectUpdate
10+
11+
12+
async def create_project(db: AsyncSession, payload: ProjectCreate) -> Project:
13+
project = Project(**payload.model_dump())
14+
db.add(project)
15+
await db.commit()
16+
await db.refresh(project)
17+
return project
18+
19+
20+
async def get_project(db: AsyncSession, project_id: UUID) -> Project | None:
21+
result = await db.execute(select(Project).where(Project.id == project_id))
22+
return result.scalar_one_or_none()
23+
24+
25+
async def list_projects(db: AsyncSession, skip: int = 0, limit: int = 100) -> tuple[list[Project], int]:
26+
count_result = await db.execute(select(func.count()).select_from(Project))
27+
total = count_result.scalar_one()
28+
result = await db.execute(select(Project).offset(skip).limit(limit))
29+
return list(result.scalars().all()), total
30+
31+
32+
async def update_project(db: AsyncSession, project_id: UUID, payload: ProjectUpdate) -> Project | None:
33+
project = await get_project(db, project_id)
34+
if project is None:
35+
return None
36+
37+
for field, value in payload.model_dump(exclude_unset=True).items():
38+
setattr(project, field, value)
39+
40+
await db.commit()
41+
await db.refresh(project)
42+
return project

backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
external_results,
1313
links,
1414
notifications,
15+
projects,
1516
requirements,
1617
suggestions,
1718
test_cases,
@@ -44,6 +45,7 @@
4445
app.include_router(users.router, prefix=settings.API_V1_PREFIX, tags=["users"])
4546
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX, tags=["notifications"])
4647
app.include_router(external_results.router, prefix=settings.API_V1_PREFIX, tags=["external_results"])
48+
app.include_router(projects.router, prefix=settings.API_V1_PREFIX, tags=["projects"])
4749

4850
# Dev-only static route: serve local artifact files when BGSTM_STORAGE_BACKEND=local.
4951
# This is intentionally NOT mounted in production (S3 or other remote backends).

backend/app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .external_case_result import ExternalCaseResult
88
from .link import LinkSource, LinkType, RequirementTestCaseLink
99
from .notification import Notification, NotificationType
10+
from .project import Project
1011
from .requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType
1112
from .runner_token import RunnerToken
1213
from .suggestion import LinkSuggestion, SuggestionMethod, SuggestionStatus
@@ -23,6 +24,7 @@
2324
"ExternalCaseResult",
2425
"Notification",
2526
"NotificationType",
27+
"Project",
2628
"Requirement",
2729
"RequirementType",
2830
"PriorityLevel",

backend/app/models/project.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import uuid
2+
3+
from sqlalchemy import Column, String, Text
4+
5+
from .base import Base, TimestampMixin
6+
from .requirement import GUID
7+
8+
9+
class Project(Base, TimestampMixin):
10+
__tablename__ = "projects"
11+
12+
id = Column(GUID(), primary_key=True, default=uuid.uuid4)
13+
name = Column(String(255), nullable=False, index=True)
14+
description = Column(Text, nullable=True)
15+
16+
def __repr__(self):
17+
return f"<Project(id={self.id}, name={self.name!r})>"

backend/app/schemas/project.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from datetime import datetime
2+
from uuid import UUID
3+
4+
from pydantic import BaseModel, ConfigDict, Field
5+
6+
7+
class ProjectBase(BaseModel):
8+
name: str = Field(..., max_length=255)
9+
description: str | None = None
10+
11+
12+
class ProjectCreate(ProjectBase):
13+
pass
14+
15+
16+
class ProjectUpdate(BaseModel):
17+
name: str | None = Field(None, max_length=255)
18+
description: str | None = None
19+
20+
21+
class ProjectResponse(ProjectBase):
22+
id: UUID
23+
created_at: datetime
24+
updated_at: datetime
25+
26+
model_config = ConfigDict(from_attributes=True)

backend/tests/api/test_external_results_audit.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
from app.db.session import get_db
1717
from app.main import app
1818
from app.models.base import Base
19+
from app.models.project import Project
1920
from app.models.user import User, UserRole
2021

22+
_PROJECT_ID = str(uuid.uuid4())
23+
2124

2225
def _make_user(role: UserRole = UserRole.admin) -> User:
2326
return User(
@@ -37,7 +40,7 @@ def _auth_header(plaintext: str) -> dict[str, str]:
3740
def _session_payload() -> dict[str, str | dict[str, str]]:
3841
return {
3942
"runner": "pytest-bgstm@1.0.0",
40-
"project_id": str(uuid.uuid4()),
43+
"project_id": _PROJECT_ID,
4144
"git_sha": "abc123",
4245
"git_branch": "main",
4346
"ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}",
@@ -53,6 +56,9 @@ async def db_session():
5356

5457
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
5558
async with factory() as session:
59+
project = Project(id=uuid.UUID(_PROJECT_ID), name=f"project-{uuid.uuid4().hex[:6]}")
60+
session.add(project)
61+
await session.commit()
5662

5763
async def _override_get_db():
5864
yield session

0 commit comments

Comments
 (0)