Skip to content

Commit c63453f

Browse files
Merge pull request #306 from bg-playground/copilot/bgstm-300-implement-session-endpoints
Implement External Results session endpoints (BGSTM#300)
2 parents ccbd945 + 2d45408 commit c63453f

8 files changed

Lines changed: 797 additions & 0 deletions

File tree

backend/alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
# Import Base.metadata for autogenerate support.
2626
# All models must be imported so their tables are registered on Base.metadata.
27+
import app.models.external_results # noqa: E402, F401
2728
import app.models.link # noqa: E402, F401
2829
import app.models.requirement # noqa: E402, F401
2930
import app.models.suggestion # noqa: E402, F401
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""add external_run_sessions table
2+
3+
Revision ID: h7i8j9k0l1m2
4+
Revises: g6h7i8j9k0l1
5+
Create Date: 2026-05-06 22:00: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 = "h7i8j9k0l1m2"
18+
down_revision: Union[str, None] = "g6h7i8j9k0l1"
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+
"external_run_sessions",
26+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
27+
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
28+
sa.Column("runner", sa.String(255), nullable=False),
29+
sa.Column(
30+
"status",
31+
sa.Enum("started", "passed", "failed", "skipped", "aborted", name="runstatus"),
32+
nullable=False,
33+
server_default="started",
34+
),
35+
sa.Column("git_sha", sa.String(255), nullable=True),
36+
sa.Column("git_branch", sa.String(255), nullable=True),
37+
sa.Column("ci_url", sa.String(2048), nullable=True),
38+
sa.Column("run_metadata", sa.JSON(), nullable=True),
39+
sa.Column("started_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
40+
sa.Column("finished_at", sa.DateTime(), nullable=True),
41+
sa.Column("summary", sa.JSON(), nullable=True),
42+
sa.Column(
43+
"created_by_runner_token_id",
44+
postgresql.UUID(as_uuid=True),
45+
sa.ForeignKey("runner_tokens.id"),
46+
nullable=False,
47+
),
48+
)
49+
op.create_index("idx_external_run_sessions_project_id", "external_run_sessions", ["project_id"])
50+
op.create_index(
51+
"idx_external_run_sessions_runner_token_id",
52+
"external_run_sessions",
53+
["created_by_runner_token_id"],
54+
)
55+
56+
57+
def downgrade() -> None:
58+
op.drop_index("idx_external_run_sessions_runner_token_id", table_name="external_run_sessions")
59+
op.drop_index("idx_external_run_sessions_project_id", table_name="external_run_sessions")
60+
op.drop_table("external_run_sessions")
61+
# Drop the enum type on PostgreSQL (no-op on other dialects).
62+
op.execute("DROP TYPE IF EXISTS runstatus")
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""API router for External Results — session endpoints (BGSTM#300).
2+
3+
Implements:
4+
POST /external-results/session – start a run (201 Created)
5+
PATCH /external-results/session/{id} – finish a run
6+
GET /external-results/session/{id} – read a session (runner OR user JWT)
7+
8+
Case-result endpoints → BGSTM#303
9+
Artifact endpoints → BGSTM#298
10+
Audit-log integration → BGSTM#297
11+
"""
12+
13+
from uuid import UUID
14+
15+
from fastapi import APIRouter, Depends, Header, HTTPException, status
16+
from sqlalchemy.ext.asyncio import AsyncSession
17+
18+
from app.auth.dependencies import (
19+
get_current_runner_token, # noqa: F401 — used inside _get_session_auth
20+
require_runner_scope,
21+
)
22+
from app.crud.external_results import create_session, finish_session_db, get_session
23+
from app.db.session import get_db
24+
from app.models.runner_token import RunnerToken
25+
from app.schemas.external_results import SessionCreate, SessionFinish, SessionResponse
26+
27+
router = APIRouter()
28+
29+
_WRITE_SCOPE = "external_results:write"
30+
_READ_SCOPE = "external_results:read"
31+
32+
33+
def _session_to_response(session) -> SessionResponse:
34+
"""Map an ExternalRunSession ORM row to a SessionResponse.
35+
36+
The ci_url column stores a plain string; SessionResponse expects an
37+
HttpUrl-compatible value. We pass it through as-is — Pydantic will
38+
validate and coerce it when constructing the model.
39+
"""
40+
return SessionResponse(
41+
id=session.id,
42+
status=session.status,
43+
started_at=session.started_at,
44+
finished_at=session.finished_at,
45+
runner=session.runner,
46+
project_id=session.project_id,
47+
git_sha=session.git_sha,
48+
git_branch=session.git_branch,
49+
ci_url=session.ci_url,
50+
metadata=session.run_metadata or {},
51+
)
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# POST /external-results/session — start a run
56+
# ---------------------------------------------------------------------------
57+
58+
59+
@router.post(
60+
"/external-results/session",
61+
response_model=SessionResponse,
62+
status_code=status.HTTP_201_CREATED,
63+
)
64+
async def create_external_session(
65+
payload: SessionCreate,
66+
db: AsyncSession = Depends(get_db),
67+
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)),
68+
) -> SessionResponse:
69+
"""Start a new external test-run session.
70+
71+
Returns the existing session if an identical session was created within the
72+
last 60 seconds (idempotency window).
73+
"""
74+
session = await create_session(db, payload=payload, runner_token_id=token.id)
75+
return _session_to_response(session)
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# PATCH /external-results/session/{session_id} — finish a run
80+
# ---------------------------------------------------------------------------
81+
82+
83+
@router.patch(
84+
"/external-results/session/{session_id}",
85+
response_model=SessionResponse,
86+
)
87+
async def finish_external_session(
88+
session_id: UUID,
89+
payload: SessionFinish,
90+
db: AsyncSession = Depends(get_db),
91+
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)), # noqa: ARG001
92+
) -> SessionResponse:
93+
"""Set the terminal status of a session.
94+
95+
Returns 404 if the session does not exist.
96+
Returns 409 if the status transition is not allowed (e.g. already finished).
97+
"""
98+
try:
99+
session = await finish_session_db(db, session_id=session_id, payload=payload)
100+
except ValueError as exc:
101+
detail = exc.args[0]
102+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
103+
104+
if session is None:
105+
raise HTTPException(
106+
status_code=status.HTTP_404_NOT_FOUND,
107+
detail={"code": "session.not_found", "message": f"Session {session_id} does not exist.", "details": None},
108+
)
109+
110+
return _session_to_response(session)
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# GET /external-results/session/{session_id} — read a session
115+
# ---------------------------------------------------------------------------
116+
117+
118+
async def _get_session_auth(
119+
authorization: str | None = Header(None),
120+
db: AsyncSession = Depends(get_db),
121+
):
122+
"""Accept either a runner token or a user JWT for read access.
123+
124+
We attempt runner-token resolution first; on failure we fall back to user
125+
JWT. A 401 is raised only when both paths fail.
126+
"""
127+
# Try runner-token path
128+
if authorization and authorization.lower().startswith("bearer bgstm_runner_"):
129+
from app.auth.dependencies import get_current_runner_token as _get_runner
130+
131+
try:
132+
return await _get_runner(authorization=authorization, db=db)
133+
except HTTPException:
134+
pass
135+
136+
# Fall back to user-JWT path via the bearer scheme
137+
138+
from app.auth.security import decode_access_token
139+
from app.crud.user import get_user
140+
141+
if authorization and authorization.lower().startswith("bearer "):
142+
raw_token = authorization.split(" ", 1)[1]
143+
payload = decode_access_token(raw_token)
144+
if payload is not None:
145+
user_id = payload.get("sub")
146+
if user_id:
147+
user = await get_user(db, user_id)
148+
if user and user.is_active:
149+
return user
150+
151+
raise HTTPException(
152+
status_code=status.HTTP_401_UNAUTHORIZED,
153+
detail={"code": "runner_token.invalid", "message": "Missing or invalid credentials.", "details": None},
154+
)
155+
156+
157+
@router.get(
158+
"/external-results/session/{session_id}",
159+
response_model=SessionResponse,
160+
)
161+
async def get_external_session(
162+
session_id: UUID,
163+
db: AsyncSession = Depends(get_db),
164+
_auth=Depends(_get_session_auth),
165+
) -> SessionResponse:
166+
"""Return a single session by ID.
167+
168+
Accepts either a runner token (any scope) or a standard user JWT.
169+
Returns 404 if the session does not exist.
170+
"""
171+
session = await get_session(db, session_id)
172+
if session is None:
173+
raise HTTPException(
174+
status_code=status.HTTP_404_NOT_FOUND,
175+
detail={"code": "session.not_found", "message": f"Session {session_id} does not exist.", "details": None},
176+
)
177+
178+
return _session_to_response(session)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""CRUD operations for External Run Sessions (BGSTM#300)."""
2+
3+
from datetime import datetime, timedelta, timezone
4+
from uuid import UUID
5+
6+
from sqlalchemy import select
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.models.external_results import ExternalRunSession, RunStatus
10+
from app.schemas.external_results import SessionCreate, SessionFinish
11+
12+
# Terminal statuses — no further transitions allowed once reached.
13+
_TERMINAL_STATUSES = {RunStatus.passed, RunStatus.failed, RunStatus.aborted}
14+
15+
# Idempotency window in seconds: duplicate session creates within this window
16+
# (same project_id, runner, ci_url, git_sha) return the existing session.
17+
_IDEMPOTENCY_WINDOW_SECONDS = 60
18+
19+
20+
async def create_session(
21+
db: AsyncSession,
22+
*,
23+
payload: SessionCreate,
24+
runner_token_id: UUID,
25+
) -> ExternalRunSession:
26+
"""Create a new ExternalRunSession, honouring the idempotency window.
27+
28+
If an active (started) session with the same (project_id, runner, ci_url,
29+
git_sha) was created within the last ``_IDEMPOTENCY_WINDOW_SECONDS``
30+
seconds by the same runner token, the existing session is returned instead
31+
of creating a duplicate.
32+
33+
# TODO(#297): Write audit entry ``external_results.session.start`` here.
34+
"""
35+
cutoff = datetime.now(tz=timezone.utc).replace(tzinfo=None) - timedelta(seconds=_IDEMPOTENCY_WINDOW_SECONDS)
36+
37+
# Normalise ci_url to a plain string so we can compare it.
38+
ci_url_str = str(payload.ci_url) if payload.ci_url is not None else None
39+
40+
stmt = (
41+
select(ExternalRunSession)
42+
.where(ExternalRunSession.project_id == payload.project_id)
43+
.where(ExternalRunSession.runner == payload.runner)
44+
.where(ExternalRunSession.created_by_runner_token_id == runner_token_id)
45+
.where(ExternalRunSession.status == RunStatus.started)
46+
.where(ExternalRunSession.started_at >= cutoff)
47+
)
48+
if ci_url_str is not None:
49+
stmt = stmt.where(ExternalRunSession.ci_url == ci_url_str)
50+
else:
51+
stmt = stmt.where(ExternalRunSession.ci_url.is_(None))
52+
53+
if payload.git_sha is not None:
54+
stmt = stmt.where(ExternalRunSession.git_sha == payload.git_sha)
55+
else:
56+
stmt = stmt.where(ExternalRunSession.git_sha.is_(None))
57+
58+
result = await db.execute(stmt)
59+
existing = result.scalar_one_or_none()
60+
if existing is not None:
61+
return existing
62+
63+
session = ExternalRunSession(
64+
project_id=payload.project_id,
65+
runner=payload.runner,
66+
status=RunStatus.started,
67+
git_sha=payload.git_sha,
68+
git_branch=payload.git_branch,
69+
ci_url=ci_url_str,
70+
run_metadata=payload.metadata,
71+
started_at=datetime.now(tz=timezone.utc).replace(tzinfo=None),
72+
created_by_runner_token_id=runner_token_id,
73+
)
74+
db.add(session)
75+
await db.commit()
76+
await db.refresh(session)
77+
return session
78+
79+
80+
async def finish_session_db(
81+
db: AsyncSession,
82+
*,
83+
session_id: UUID,
84+
payload: SessionFinish,
85+
) -> ExternalRunSession | None:
86+
"""Apply the finish payload to the session and return the updated model.
87+
88+
Returns ``None`` if the session does not exist.
89+
90+
Raises ``ValueError`` with a structured dict payload on transition
91+
violations so the API layer can return the appropriate 409.
92+
93+
# TODO(#297): Write audit entry ``external_results.session.finish`` here.
94+
"""
95+
result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id))
96+
session = result.scalar_one_or_none()
97+
if session is None:
98+
return None
99+
100+
current = RunStatus(session.status)
101+
102+
# All terminal statuses block further transitions.
103+
if current in _TERMINAL_STATUSES:
104+
raise ValueError(
105+
{
106+
"code": "session.transition.invalid",
107+
"message": (
108+
f"Cannot transition session from '{current.value}' to '{payload.status.value}': "
109+
f"session is already in a terminal state."
110+
),
111+
"details": {"current_status": current.value, "requested_status": payload.status.value},
112+
}
113+
)
114+
115+
session.status = payload.status
116+
session.summary = payload.summary
117+
session.finished_at = datetime.now(tz=timezone.utc).replace(tzinfo=None)
118+
await db.commit()
119+
await db.refresh(session)
120+
return session
121+
122+
123+
async def get_session(
124+
db: AsyncSession,
125+
session_id: UUID,
126+
) -> ExternalRunSession | None:
127+
"""Return a single ExternalRunSession by primary key, or None."""
128+
result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id))
129+
return result.scalar_one_or_none()

backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
analytics,
88
audit_log,
99
auth,
10+
external_results,
1011
links,
1112
notifications,
1213
requirements,
@@ -40,6 +41,7 @@
4041
app.include_router(audit_log.router, prefix=settings.API_V1_PREFIX, tags=["audit_log"])
4142
app.include_router(users.router, prefix=settings.API_V1_PREFIX, tags=["users"])
4243
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX, tags=["notifications"])
44+
app.include_router(external_results.router, prefix=settings.API_V1_PREFIX, tags=["external_results"])
4345

4446

4547
@app.on_event("startup")

0 commit comments

Comments
 (0)