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
64 changes: 64 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
.eggs/
*.egg

# Virtual environments
.venv/
venv/
ENV/
env/

# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
*.cover

# IDE
.vscode/
.idea/
*.swp
*.swo
*~

# Git
.git/
.gitignore
.gitattributes

# CI/CD
.github/
.gitlab-ci.yml

# Documentation
*.md
docs/

# Docker
Dockerfile*
.dockerignore
docker-compose*.yml
build.sh
push.sh

# Database
db/migrations/
*.sql

# Environment
.env
.env.local
.env.*.local

# Tests
tests/
pytest.ini
41 changes: 25 additions & 16 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
FROM python:slim-bookworm
FROM python:3.12-slim-bookworm AS builder

WORKDIR /app

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy

COPY requirements.txt .

RUN uv venv /app/.venv && \
uv pip install -r requirements.txt && \
uv run opentelemetry-bootstrap -a requirements | uv pip install --requirement -

FROM python:3.12-slim-bookworm

ARG VERSION
ARG SEMVER_CORE
ARG COMMIT_SHA
ARG GITHUB_REPO
ARG BUILD_DATE

ENV VERSION=${VERSION}
ENV SEMVER_CORE=${SEMVER_CORE}
ENV COMMIT_SHA=${COMMIT_SHA}
ENV BUILD_DATE=${BUILD_DATE}
ENV GITHUB_REPO=${GITHUB_REPO}

LABEL org.opencontainers.image.source=${GITHUB_REPO}
LABEL org.opencontainers.image.created=${BUILD_DATE}
LABEL org.opencontainers.image.version=${VERSION}
LABEL org.opencontainers.image.revision=${COMMIT_SHA}

RUN set -e \
&& useradd -ms /bin/bash -d /app app
RUN useradd -ms /bin/bash -d /app app

WORKDIR /app
USER app

ENV PATH="$PATH:/app/.local/bin/"
COPY --from=builder --chown=app:app /app/.venv /app/.venv

COPY requirements.txt /app/
COPY --chown=app:app . /app/

RUN set -e \
&& pip install --no-cache-dir -r /app/requirements.txt --break-system-packages \
&& opentelemetry-bootstrap -a install
USER app

COPY --chown=app:app . /app/
ENV VERSION=${VERSION} \
SEMVER_CORE=${SEMVER_CORE} \
COMMIT_SHA=${COMMIT_SHA} \
BUILD_DATE=${BUILD_DATE} \
GITHUB_REPO=${GITHUB_REPO}

CMD ["/app/run.sh"]
107 changes: 81 additions & 26 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
import asyncio
import logging
import os
import sys
import traceback
import uuid
from contextlib import asynccontextmanager
from typing import Annotated

import asyncpg
import blibs
import fastapi_structured_logging
import httpx
from asgi_logger.middleware import AccessLoggerMiddleware
from fastapi import FastAPI
from fastapi import Header
from fastapi import HTTPException
from fastapi.middleware import Middleware
from fastapi import Request
from fastapi import status
from fastapi.responses import JSONResponse
from fastapi.responses import RedirectResponse

import webhook
Expand All @@ -24,16 +27,25 @@
from gitlab_model import PipelinePayload
from periodic_cleanup import periodic_cleanup

# from fastapi.middleware.cors import CORSMiddleware

config = DefaultConfig()

# Configure logging
blibs.init_root_logger()
logger = logging.getLogger(__name__)
logging.getLogger("urllib3").setLevel(logging.ERROR)
logging.getLogger("msrest").setLevel(logging.ERROR)
logging.getLogger("msal").setLevel(logging.ERROR)
# Configure structured logging
log_format = os.getenv("LOG_FORMAT", "auto").lower()
log_level = os.getenv("LOG_LEVEL", "INFO").upper()

if log_format == "json":
fastapi_structured_logging.setup_logging(json_logs=True, log_level=log_level)
elif log_format == "line":
fastapi_structured_logging.setup_logging(json_logs=False, log_level=log_level)
else:
fastapi_structured_logging.setup_logging(log_level=log_level)

logging.getLogger("uvicorn.error").disabled = True

# Suppress traceback printing to stderr
traceback.print_exception = lambda *args, **kwargs: None

logger = fastapi_structured_logging.get_logger()


@asynccontextmanager
Expand All @@ -52,22 +64,60 @@ async def lifespan(app: FastAPI):
title="Teams Notifier gitlab-mr-api",
version=os.environ.get("VERSION", "v0.0.0-dev"),
lifespan=lifespan,
middleware=[
Middleware(
AccessLoggerMiddleware, # type: ignore
format='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(L)ss', # noqa # type: ignore
)
],
)

# Configure CORS
# app.add_middleware(
# CORSMiddleware,
# allow_origins=["*"], # Allows all origins
# allow_credentials=True,
# allow_methods=["*"], # Allows all methods
# allow_headers=["*"], # Allows all headers
# )
app.add_middleware(fastapi_structured_logging.AccessLogMiddleware)


@app.exception_handler(asyncpg.UniqueViolationError)
async def database_uniqueviolation_handler(request: Request, exc: asyncpg.UniqueViolationError):
logger.error(
"database unique violation error",
error_type=type(exc).__name__,
error_detail=str(exc),
constraint=getattr(exc, "constraint_name", None),
path=request.url.path,
method=request.method,
)
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "Resource already exists", "error": str(exc)},
)


@app.exception_handler(asyncpg.PostgresError)
async def database_exception_handler(request: Request, exc: asyncpg.PostgresError):
logger.error(
"database error",
error_type=type(exc).__name__,
error_detail=str(exc),
sqlstate=getattr(exc, "sqlstate", None),
path=request.url.path,
method=request.method,
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Database error occurred"},
)


@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
exc_type, exc_value, exc_traceback = sys.exc_info()

logger.error(
"unhandled exception",
error_type=type(exc).__name__,
error_detail=str(exc),
path=request.url.path,
method=request.method,
traceback="".join(traceback.format_exception(exc_type, exc_value, exc_traceback)),
)

return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"},
)


@app.get("/", response_class=RedirectResponse, status_code=302)
Expand Down Expand Up @@ -141,7 +191,12 @@ async def healthcheck():
result = await connection.fetchval("SELECT true FROM merge_request_ref")
return {"ok": result}
except Exception as e:
logger.exception(f"health check failed with {type(e)}: {e}")
logger.error(
"health check failed",
error_type=type(e).__name__,
error_detail=str(e),
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"{type(e)}: {e}")


Expand Down
12 changes: 7 additions & 5 deletions cards/merge_request.yaml.j2
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
$schema: https://adaptivecards.io/schemas/adaptive-card.json
type: AdaptiveCard
version: '1.5'
msteams:
width: Full
fallbackText: {{ fallback }}
speak: {{ fallback }}
body:
Expand Down Expand Up @@ -79,8 +81,8 @@ body:
- type: Table
showGridLines: false
columns:
- width: 3
- width: 7
- width: 130px
- width: 1
firstRowAsHeaders: false
rows:
- type: TableRow
Expand Down Expand Up @@ -167,7 +169,7 @@ body:
verticalContentAlignment: Center
items:
- type: TextBlock
text: '{{ precalc.assignees | join(",") }}'
text: '{{ precalc.assignees | join(", ") }}'
#size: Small
wrap: true
{% endif %}
Expand All @@ -186,7 +188,7 @@ body:
verticalContentAlignment: Center
items:
- type: TextBlock
text: '{{ precalc.reviewers | join(",") }}'
text: '{{ precalc.reviewers | join(", ") }}'
#size: Small
wrap: true
{% endif %}
Expand All @@ -205,7 +207,7 @@ body:
verticalContentAlignment: Center
items:
- type: TextBlock
text: '{{ precalc.approvers | join(",") }}'
text: '{{ precalc.approvers | join(", ") }}'
#size: Small
wrap: true
{% endif %}
Expand Down
1 change: 1 addition & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class DefaultConfig:
DATABASE_POOL_MAX_SIZE = int(os.environ.get("DATABASE_POOL_MAX_SIZE", "10"))
LOG_QUERIES = os.environ.get("LOG_QUERIES", "")
VALID_X_GITLAB_TOKEN = os.environ.get("VALID_X_GITLAB_TOKEN", "")
MESSAGE_DELETE_DELAY_SECONDS = int(os.environ.get("MESSAGE_DELETE_DELAY_SECONDS", "30"))
_valid_tokens: list[str]

def __init__(self):
Expand Down
4 changes: 2 additions & 2 deletions db.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
import asyncio
import json
import logging
import urllib.parse
from typing import Any
from typing import Literal

import asyncpg.connect_utils
import fastapi_structured_logging
from pydantic import BaseModel

from config import config
Expand All @@ -15,7 +15,7 @@
from gitlab_model import MergeRequestPayload
from gitlab_model import PipelinePayload

log = logging.getLogger(__name__)
log = fastapi_structured_logging.get_logger()

__all__ = ["database", "dbh"]

Expand Down
11 changes: 11 additions & 0 deletions db/migrations/20250121000000_add_webhook_fingerprint_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- migrate:up
CREATE TABLE gitlab_mr_api.webhook_fingerprint (
fingerprint character varying(64) NOT NULL,
processed_at timestamp with time zone DEFAULT now() NOT NULL
);

ALTER TABLE ONLY gitlab_mr_api.webhook_fingerprint
ADD CONSTRAINT webhook_fingerprint_pkey PRIMARY KEY (fingerprint);

-- migrate:down
DROP TABLE gitlab_mr_api.webhook_fingerprint;
Loading