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
2 changes: 0 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Environment and secrets
.env
*.env
config_files/credentials_config.json

# Version control
.git/
Expand All @@ -22,7 +21,6 @@ venv/
.venv/

# Docker files (not needed inside the image)
dockerfile
docker-compose.yml

# Documentation and non-runtime files
Expand Down
6 changes: 3 additions & 3 deletions dockerfile → Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ── Stage 1: install dependencies in a throwaway builder ──────────────
FROM python:3.9-slim AS builder
FROM python:3.14-slim AS builder

# Prevent .pyc files and enable unbuffered stdout/stderr
ENV PYTHONDONTWRITEBYTECODE=1 \
Expand All @@ -13,7 +13,7 @@ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt


# ── Stage 2: lean runtime image ──────────────────────────────────────
FROM python:3.9-slim AS runtime
FROM python:3.14-slim AS runtime

LABEL maintainer="Learning Dashboard team" \
description="LD Connect Event – webhook ingestion service"
Expand Down Expand Up @@ -41,7 +41,7 @@ EXPOSE 5000

# Healthcheck so Docker / Compose can monitor the service
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1

# Run gunicorn with the create_app() factory
CMD ["gunicorn", \
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ pip install -r requirements.txt
# copy sample env and edit credentials / secrets
cp template.env .env

# create the directory that will contain your per-project API credentials
mkdir -p config_files

# run development server (single worker)
python app.py
```
Expand All @@ -91,9 +94,14 @@ docker compose up -d --build ld_connect
```

* Exposes the service on port **5000** inside the container
* Mounts `./config_files` into `/app/config_files` as read-only
* Behind Nginx / Traefik, route
`https://<your-domain>/webhook/{github|taiga|excel}` → `ld_connect:5000`

Before building or starting the service, make sure
`config_files/credentials_config.json` exists. It is used during local image builds
and can also be provided at runtime through the `./config_files` mount.

---

## Environment variables
Expand Down Expand Up @@ -186,7 +194,20 @@ Configured hooks:

### What's the origin and purpouse of credentials_config.json?

Basically, when LD Connect receives an event from GitHub or Taiga, it often needs to fetch additional details about the event (e.g., commit info, issue details) by calling the respective APIs. To authenticate these API calls, LD Connect uses tokens that are specific to each project or team. The `credentials_config.json` file serves as a mapping between project identifiers (like "TeamA") and their corresponding API tokens. This way, when an event comes in with a `prj` parameter, LD Connect can look up the correct token to use for any API requests related to that event.
Basically, when LD Connect receives an event from GitHub or Taiga, it often needs to fetch additional details about the event (e.g., commit info, issue details) by calling the respective APIs. To authenticate these API calls, LD Connect uses tokens that are specific to each project or team. The `credentials_config.json` file serves as a mapping between project identifiers (like "TeamA") and their corresponding API tokens. This way, when an event comes in with a `prj` parameter, LD Connect can look up the correct token to use for any API requests related to that event.

Minimal example:

```json
{
"course_a": {
"github_token": "ghp_replace_me",
"taiga_user": "replace-me",
"taiga_password": "replace-me",
"teams": ["TeamAlpha", "TeamBeta"]
}
}
```

## Can i use LD Connect alone, without LD-infrastructure?

Expand Down
50 changes: 49 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from flask import Flask
from flask import Flask, jsonify
from routes.github_routes import github_bp
from routes.taiga_routes import taiga_bp
from routes.excel_routes import excel_bp
from config.credentials_loader import (
CredentialsConfigError,
ProjectCredentialsNotFoundError,
validate_config_file,
)
from config.logger_config import setup_logging
import logging
import os
Expand All @@ -10,13 +15,56 @@
logger = logging.getLogger(__name__)


def _register_error_handlers(app: Flask) -> None:
@app.errorhandler(CredentialsConfigError)
def handle_credentials_config_error(exc):
logger.error("Credentials configuration error: %s", exc)
return (
jsonify(
{
"error": "Credentials configuration error",
"details": str(exc),
}
),
500,
)

Check warning on line 30 in app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Specify an explicit HTTP status code for this error handler.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ0kURDPDAKfaaDBD9Ex&open=AZ0kURDPDAKfaaDBD9Ex&pullRequest=17

@app.errorhandler(ProjectCredentialsNotFoundError)
def handle_unknown_project(exc):
logger.warning("Unknown project in credentials config: %s", exc)
return (
jsonify(
{
"error": "Unknown project credentials",
"details": str(exc),
}
),
400,
)

Check warning on line 43 in app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Specify an explicit HTTP status code for this error handler.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ0kURDPDAKfaaDBD9Ey&open=AZ0kURDPDAKfaaDBD9Ey&pullRequest=17


def _log_credentials_config_status() -> None:
try:
config_path = validate_config_file()
except CredentialsConfigError as exc:
logger.warning("%s", exc)
else:
logger.info("Using credentials config at %s", config_path)


def create_app():
app = Flask(__name__)

@app.get("/health")
def health():
return jsonify({"status": "ok"}), 200

# Register blueprint routes
app.register_blueprint(github_bp)
app.register_blueprint(taiga_bp)
app.register_blueprint(excel_bp)
_register_error_handlers(app)
_log_credentials_config_status()
logger.info("Flask created and Blueprints registered successfully.")
return app

Expand Down
73 changes: 64 additions & 9 deletions config/credentials_loader.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,65 @@
import json
import os
from typing import Optional
from pathlib import Path
from typing import Any, Optional

CONFIG_FILE = os.getenv("CREDENTIALS_FILE", "config_files/credentials_config.json")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_FILE_ENV = "CREDENTIALS_FILE"
DEFAULT_CONFIG_FILE = Path("config_files/credentials_config.json")


def load():
with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
return json.load(fh)
class CredentialsConfigError(RuntimeError):
"""Base error for credentials configuration issues."""


class CredentialsConfigNotFoundError(FileNotFoundError, CredentialsConfigError):
"""Raised when the credentials config file cannot be found."""


class CredentialsConfigInvalidError(CredentialsConfigError, ValueError):
"""Raised when the credentials config file cannot be parsed."""


class ProjectCredentialsNotFoundError(KeyError):
"""Raised when a team/project is not present in the credentials config."""


def _raw_config_value(config_file: Optional[str] = None) -> str:
return config_file or os.getenv(CONFIG_FILE_ENV) or str(DEFAULT_CONFIG_FILE)


def get_config_path(config_file: Optional[str] = None) -> Path:
raw_value = _raw_config_value(config_file)
raw_path = Path(raw_value).expanduser()
if raw_path.is_absolute():
return raw_path
return (PROJECT_ROOT / raw_path).resolve()


def validate_config_file(config_file: Optional[str] = None) -> Path:
raw_value = _raw_config_value(config_file)
config_path = get_config_path(raw_value)
if config_path.is_file():
return config_path

raise CredentialsConfigNotFoundError(
"Credentials config file not found. "
f"Resolved {CONFIG_FILE_ENV}={raw_value!r} to {config_path}. "
"Create config_files/credentials_config.json, mount ./config_files "
f"into /app/config_files, or set {CONFIG_FILE_ENV} to an absolute path."
)


def load() -> dict[str, Any]:
config_path = validate_config_file()
try:
with config_path.open("r", encoding="utf-8") as fh:
return json.load(fh)
except json.JSONDecodeError as exc:
raise CredentialsConfigInvalidError(
f"Credentials config file at {config_path} contains invalid JSON: "
f"{exc.msg}"
) from exc


def resolve(prj: str, field: str) -> Optional[str]:
Expand All @@ -16,8 +68,11 @@ def resolve(prj: str, field: str) -> Optional[str]:
that corresponds to <prj>. Raise KeyError if not configured.
"""
cfg = load()
for course, props in cfg.items():
if prj in props["teams"]:

config_path = get_config_path()
for props in cfg.values():
if prj in props.get("teams", []):
return props.get(field)
raise KeyError(f"Project {prj!r} not found in {CONFIG_FILE}")

raise ProjectCredentialsNotFoundError(
f"Project {prj!r} not found in credentials config {config_path}"
)
52 changes: 36 additions & 16 deletions datasources/requests/taiga_api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
TTL = timedelta(
minutes=1
) # Cache time-to-live, set to 5 minutes. Means that if the same request is made within 5 minutes, it will return the cached result instead of making a new API call.
MILESTONE_TIMEOUT = (3, 8)


def _empty_stats():
return {
"milestone_total_points": 0,
"milestone_closed_points": 0,
"milestone_total_userstories": 0,
"milestone_completed_userstories": 0,
"milestone_total_tasks": 0,
"milestone_completed_tasks": 0,
}


def milestone_stats(project_id: str, milestone_id: str, prj: str):
Expand All @@ -37,7 +49,18 @@
"****" if psw else None,
)
if user and psw:
token = get_taiga_token(user, psw)
try:
token = get_taiga_token(user, psw)
except requests.exceptions.RequestException as exc:
logger.warning(
"Failed to get Taiga token for project %s. Returning empty milestone stats: %s",
prj,
exc,
)

Check warning on line 59 in datasources/requests/taiga_api_call.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ0kURBeDAKfaaDBD9Ev&open=AZ0kURBeDAKfaaDBD9Ev&pullRequest=17
stats = _empty_stats()
_CACHE[key] = (now, stats)
return stats

headers = {"Authorization": f"Bearer {token}"}
logger.debug("Using Taiga credentials for project %s", prj)
else:
Expand All @@ -46,23 +69,20 @@

url = f"{TAIGA_API_URL}/milestones/{milestone_id}/stats"
logger.debug("Fetching Taiga milestone stats from URL: %s", url)
r = requests.get(
url, params={"project": project_id}, headers=headers, timeout=(1, 5)
)

try:
r = requests.get(
url, params={"project": project_id}, headers=headers, timeout=MILESTONE_TIMEOUT
)
r.raise_for_status()
except requests.exceptions.HTTPError as e:
print(f"Warning: Failed to fetch milestone stats (status {r.status_code}): {e}")
# Return empty stats if we can't access the milestone
stats = {
"milestone_total_points": 0,
"milestone_closed_points": 0,
"milestone_total_userstories": 0,
"milestone_completed_userstories": 0,
"milestone_total_tasks": 0,
"milestone_completed_tasks": 0,
}
except requests.exceptions.RequestException as exc:
logger.warning(
"Failed to fetch milestone stats for project %s milestone %s. "
"Returning empty milestone stats: %s",
prj,
milestone_id,
exc,
)

Check warning on line 84 in datasources/requests/taiga_api_call.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ0kURBeDAKfaaDBD9Ew&open=AZ0kURBeDAKfaaDBD9Ew&pullRequest=17
stats = _empty_stats()
_CACHE[key] = (now, stats)
return stats

Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ services:
ld_connect:
build:
context: .
dockerfile: dockerfile
dockerfile: Dockerfile
container_name: LDConnect
environment:
- EVAL_HOST=ld_eval
- EVAL_PORT=5001
env_file:
- .env
volumes:
- ./config_files:/app/config_files:ro
networks:
- qrapids
depends_on:
Expand Down
23 changes: 21 additions & 2 deletions routes/taiga_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,27 @@
logger.info("Deleting document from %s. ID=%s", collection_name, id)
if not id:
return jsonify({"error": "No object ID"}), 400
coll.delete_one({f"{event_type}_id": id})
logger.info("Document with %s=%s has been deleted.", event_type, id)
# relateduserstory docs are stored in the same collection/key as userstories
delete_key = "userstory_id" if event_type == "relateduserstory" else f"{event_type}_id"
result = coll.delete_one({delete_key: id})
logger.info(
"Delete attempted in %s with %s=%s. deleted_count=%s",
collection_name, delete_key, id, result.deleted_count
)

Check warning on line 73 in routes/taiga_routes.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ0kURAzDAKfaaDBD9Eu&open=AZ0kURAzDAKfaaDBD9Eu&pullRequest=17

author_login = raw_payload.get("by", {}).get("username", "unknown")
logger.info(
"Notifying LD_EVAL about deleted event: %s for team with external_id: %s with quality_model: %s",
event_type,
prj,
quality_model,
)

Check warning on line 81 in routes/taiga_routes.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ0kURAzDAKfaaDBD9Et&open=AZ0kURAzDAKfaaDBD9Et&pullRequest=17
try:
notify_eval_push(event_type, prj, author_login, quality_model)
except Exception as e:
logger.error("Error notifying LD_EVAL: %s", e)
return jsonify({"error": "Failed to notify LD_EVAL"}), 500

return jsonify({"status": "ok"}), 200

# Parse the raw JSON payload using the parse_taiga_event function
Expand Down
2 changes: 2 additions & 0 deletions template.env
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ MONGO_USER=
MONGO_PASS=
MONGO_AUTHSRC=

# Relative paths are resolved from the project root. In Docker, the default
# expects ./config_files to be mounted into /app/config_files.
CREDENTIALS_FILE=config_files/credentials_config.json

#### GitHub configuration
Expand Down
Loading
Loading