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: 2 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from routes.github_routes import github_bp
from routes.taiga_routes import taiga_bp
from routes.excel_routes import excel_bp
from routes.recovery_routes import recovery_bp
from config.credentials_loader import (
CredentialsConfigError,
ProjectCredentialsNotFoundError,
Expand Down Expand Up @@ -63,6 +64,7 @@ def health():
app.register_blueprint(github_bp)
app.register_blueprint(taiga_bp)
app.register_blueprint(excel_bp)
app.register_blueprint(recovery_bp)

app.register_blueprint(github_bp, url_prefix="/webhooks", name="github_bp_prefixed")
app.register_blueprint(taiga_bp, url_prefix="/webhooks", name="taiga_bp_prefixed")
Expand Down
34 changes: 30 additions & 4 deletions datasources/requests/taiga_api_call.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import logging
import requests
from contextvars import ContextVar
from datetime import datetime, timedelta, timezone
from utils.taiga_token.taiga_auth import get_taiga_token
from config.credentials_loader import resolve
from config.settings import TAIGA_API_URL, TAIGA_TOKEN
from config.settings import TAIGA_API_URL, TAIGA_TOKEN, TAIGA_USERNAME, TAIGA_PASSWORD

_CACHE = {} # key = (project_id, milestone_id) -> (timestamp, stats)
_DETAILS_CACHE = {} # key = (project_id, milestone_id) -> (timestamp, details)
Expand All @@ -14,6 +15,27 @@
log = logging.getLogger(__name__)
logger = log
TAIGA_LOOKUP_ERRORS = (requests.RequestException,)
_TAIGA_TOKEN_OVERRIDE: ContextVar[str | None] = ContextVar("taiga_token_override", default=None)


def _auth_header(token: str) -> dict:
"""Return the correct Authorization header for a Taiga token.

ApplicationTokens contain colons (base64:timestamp:hash) and require the
'Application' scheme; regular session tokens use 'Bearer'.
"""
prefix = "Application" if ":" in token else "Bearer"
return {"Authorization": f"{prefix} {token}"}


def push_taiga_token_override(token: str | None):
"""Temporarily override Taiga bearer token for the current request context."""
normalized = token.strip() if isinstance(token, str) and token.strip() else None
return _TAIGA_TOKEN_OVERRIDE.set(normalized)


def pop_taiga_token_override(token_handle):
_TAIGA_TOKEN_OVERRIDE.reset(token_handle)


def _empty_stats():
Expand All @@ -29,15 +51,19 @@ def _empty_stats():

def _build_taiga_headers(prj: str):
"""Return Taiga headers for public, private and SSO deployments."""
token_override = _TAIGA_TOKEN_OVERRIDE.get()
if token_override:
return _auth_header(token_override)

if TAIGA_TOKEN:
return {"Authorization": f"Bearer {TAIGA_TOKEN}"}
return _auth_header(TAIGA_TOKEN)

try:
user = resolve(prj, "taiga_user")
psw = resolve(prj, "taiga_password")
except KeyError:
log.warning("No Taiga credentials configured for project %s; using anonymous requests.", prj)
return {}
user = TAIGA_USERNAME
psw = TAIGA_PASSWORD

if user and psw:
try:
Expand Down
172 changes: 172 additions & 0 deletions routes/recovery_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from datetime import timezone
from urllib.parse import urlparse

import logging
from flask import Blueprint, jsonify, request

from utils.recovery.github_recovery import collect_github, parse_dt, get_organization_repos
from utils.recovery.taiga_recovery import main as taiga_recovery_main
from config.settings import GITHUB_TOKEN

logger = logging.getLogger(__name__)

recovery_bp = Blueprint("recovery_bp", __name__)


def _parse_github_url(value: str) -> tuple[str, str | None]:
if not value:
raise ValueError("github_url is required")

raw = value.strip()
if raw.endswith(".git"):
raw = raw[:-4]

if "://" not in raw:
parts = [part for part in raw.split("/") if part]
if len(parts) >= 2:
return parts[0], parts[1]
if len(parts) == 1:
return parts[0], None
raise ValueError("github_url must contain organization (and optionally repository)")

parsed = urlparse(raw)
parts = [part for part in parsed.path.split("/") if part]
if len(parts) < 1:
raise ValueError("github_url must contain organization (and optionally repository)")

# Supports https://github.com/org or https://github.com/org/repo
# Also supports API style paths like /repos/org/repo
if parts[0] == "repos" and len(parts) >= 3:
return parts[1], parts[2]

if len(parts) == 1:
return parts[0], None

return parts[0], parts[1]


def _parse_taiga_slug(value: str) -> str:
if not value:
raise ValueError("taiga_url is required")

raw = value.strip()
if "://" not in raw:
return raw.strip("/")

parsed = urlparse(raw)
parts = [part for part in parsed.path.split("/") if part]
if not parts:
raise ValueError("taiga_url does not contain a project slug")

if "project" in parts:
project_index = parts.index("project")
if project_index + 1 < len(parts):
return parts[project_index + 1]

return parts[-1]


def _to_iso_utc(value: str | None) -> str | None:
if not value:
return None
return (
parse_dt(value)
.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)


@recovery_bp.route("/admin/recovery/team", methods=["POST"])
def run_team_recovery():

Check failure on line 82 in routes/recovery_routes.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ3PEZw9hDgmWKHkOOC5&open=AZ3PEZw9hDgmWKHkOOC5&pullRequest=23
payload = request.get_json(silent=True) or {}

prj = (payload.get("prj") or "").strip()
github_url = (payload.get("github_url") or "").strip()
taiga_url = (payload.get("taiga_url") or "").strip()

if not prj:
return jsonify({"error": "prj is required"}), 400
if not github_url:
return jsonify({"error": "github_url is required"}), 400
if not taiga_url:
return jsonify({"error": "taiga_url is required"}), 400

quality_model = (payload.get("quality_model") or "default").strip() or "default"
from_date = payload.get("from_date")
to_date = payload.get("to_date")
github_events = payload.get("github_events") or ["commits", "issues", "pull_requests"]
taiga_events = payload.get("taiga_events") or ["task", "userstory", "issue", "epic"]
github_token = (payload.get("github_token") or "").strip() or None
taiga_token = (payload.get("taiga_token") or "").strip() or None

try:
org, repo = _parse_github_url(github_url)
slug = _parse_taiga_slug(taiga_url)
since = _to_iso_utc(from_date)
until = _to_iso_utc(to_date)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400

steps = []

try:
headers = {"Accept": "application/vnd.github+json"}
effective_github_token = github_token if github_token is not None else GITHUB_TOKEN
if effective_github_token:
headers["Authorization"] = f"Bearer {effective_github_token}"

repos_to_recover = [repo] if repo else get_organization_repos(org, headers)
if not repos_to_recover:
raise ValueError(f"No repositories found for organization '{org}'")

for repo_name in repos_to_recover:
logger.info("Starting GitHub recovery for team %s (%s/%s)", prj, org, repo_name)
collect_github(
org=org,
repo=repo_name,
prj=prj,
events=list(github_events),
since=since,
until=until,
quality_model=quality_model,
github_token=effective_github_token,
)
steps.append({"source": "github", "status": "ok"})
except Exception as exc:
logger.exception("GitHub recovery failed for team %s", prj)
steps.append({"source": "github", "status": "error", "error": str(exc)})
return jsonify({"status": "error", "steps": steps}), 500

try:
logger.info("Starting Taiga recovery for team %s (%s)", prj, slug)
taiga_args = [
"--slug",
slug,
"--prj",
prj,
"--events",
",".join(taiga_events),
"--quality-model",
quality_model,
]
if from_date:
taiga_args.extend(["--from-date", from_date])
if to_date:
taiga_args.extend(["--to-date", to_date])
if taiga_token:
taiga_args.extend(["--taiga-token", taiga_token])

taiga_recovery_main(taiga_args)
steps.append({"source": "taiga", "status": "ok"})
except SystemExit as exc:

Check failure on line 163 in routes/recovery_routes.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Reraise this exception to stop the application as the user expects

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ3PEZw9hDgmWKHkOOC6&open=AZ3PEZw9hDgmWKHkOOC6&pullRequest=23
logger.exception("Taiga recovery exited for team %s", prj)
steps.append({"source": "taiga", "status": "error", "error": str(exc)})
return jsonify({"status": "error", "steps": steps}), 500
except Exception as exc:
logger.exception("Taiga recovery failed for team %s", prj)
steps.append({"source": "taiga", "status": "error", "error": str(exc)})
return jsonify({"status": "error", "steps": steps}), 500

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