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: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
sudo chmod +x /usr/local/bin/gitleaks

- name: Run Gitleaks
run: gitleaks detect --source . --redact --verbose --baseline-path .gitleaks-baseline.json --exit-code 1
run: gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --redact --verbose --exit-code 1

semgrep:
runs-on: ubuntu-latest
Expand Down
271 changes: 188 additions & 83 deletions .gitleaks-baseline.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
title = "Gitleaks config"

[extend]
useDefault = true

[[allowlists]]
description = "Ignore generated Gitleaks baseline report"
paths = [
'''^\.gitleaks-baseline\.json$'''
]
4 changes: 4 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
app.register_blueprint(github_bp)
app.register_blueprint(taiga_bp)
app.register_blueprint(excel_bp)

app.register_blueprint(github_bp, url_prefix="/webhooks", name="github_bp_prefixed")

Check failure on line 67 in app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "/webhooks" 3 times.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ2RBWL2S8hne6nDOeG5&open=AZ2RBWL2S8hne6nDOeG5&pullRequest=19
app.register_blueprint(taiga_bp, url_prefix="/webhooks", name="taiga_bp_prefixed")
app.register_blueprint(excel_bp, url_prefix="/webhooks", name="excel_bp_prefixed")
_register_error_handlers(app)
_log_credentials_config_status()
logger.info("Flask created and Blueprints registered successfully.")
Expand Down
7 changes: 4 additions & 3 deletions datasources/github_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ def parse_github_push_event(raw_payload: Dict, prj: str) -> Dict:

# The 'sender' object is at the top level
sender = raw_payload.get("sender", {})
sender_login = sender.get("login") or "anonymous"
sender_info = {
"id": sender.get("id", ""),
"login": sender.get("login", ""),
"login": sender_login,
"url": sender.get("url", ""),
"type": sender.get("type", ""),
"site_admin": sender.get("site_admin", False),
Expand All @@ -54,8 +55,8 @@ def parse_github_push_event(raw_payload: Dict, prj: str) -> Dict:
date = to_madrid_local(c.get("timestamp"))

# Built author information
author_login = c.get("author", {}).get("username", "")
author_name = c.get("author", {}).get("name", "")
author_login = c.get("author", {}).get("username") or sender_login
author_name = c.get("author", {}).get("name", "")
author_email = c.get("author", {}).get("email", "")

# Compute message stats
Expand Down
156 changes: 113 additions & 43 deletions datasources/requests/taiga_api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
import requests
from datetime import datetime, timedelta, timezone
from utils.taiga_token.taiga_auth import get_taiga_token
from config.credentials_loader import resolve
from config.credentials_loader import (
CredentialsConfigError,
ProjectCredentialsNotFoundError,
resolve,
)

from config.settings import TAIGA_API_URL

_CACHE = {} # key = (project_id, milestone_id) -> (timestamp, stats)
_DETAILS_CACHE = {} # key = (project_id, milestone_id) -> (timestamp, details)
_USERSTORY_CACHE = {} # key = (project_id, userstory_id) -> (timestamp, details)
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.
logger = logging.getLogger(__name__)

_CACHE = {} # key = (project_id, milestone_id) -> (timestamp, stats)
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)
TAIGA_LOOKUP_ERRORS = (
requests.exceptions.RequestException,
CredentialsConfigError,
ProjectCredentialsNotFoundError,
)


def _empty_stats():
Expand All @@ -26,6 +36,96 @@ def _empty_stats():
}



def _build_taiga_headers(prj: str):
"""Return the Taiga headers needed for public and private deployments."""
if "api.taiga.io" in TAIGA_API_URL:
user = resolve(prj, "taiga_user")
psw = resolve(prj, "taiga_password")
if user and psw:
token = get_taiga_token(user, psw)
return {"Authorization": f"Bearer {token}"}
return {}


def milestone_details(project_id: str, milestone_id: str, prj: str):
"""
Fetches the milestone metadata from Taiga.
Returns the raw milestone fields needed to enrich recovery documents.
"""
if not project_id or not milestone_id:
return {}

key = (project_id, milestone_id)
now = datetime.now(timezone.utc)
if key in _DETAILS_CACHE and now - _DETAILS_CACHE[key][0] < TTL:
return _DETAILS_CACHE[key][1]

try:
headers = _build_taiga_headers(prj)
url = f"{TAIGA_API_URL}/milestones/{milestone_id}"
r = requests.get(
url, params={"project": project_id}, headers=headers, timeout=(1, 5)
)
r.raise_for_status()
except TAIGA_LOOKUP_ERRORS as exc:
logger.warning(
"Failed to fetch milestone details for project %s milestone %s: %s",
project_id,
milestone_id,
exc,
)
return {}

js = r.json()
details = {
"milestone_created_date": js.get("created_date"),
"milestone_modified_date": js.get("modified_date"),
"milestone_name": js.get("name"),
"estimated_start": js.get("estimated_start"),
"estimated_finish": js.get("estimated_finish"),
"milestone_closed": bool(js.get("closed", False)),
}
_DETAILS_CACHE[key] = (now, details)
return details


def userstory_details(project_id: str, userstory_id: str, prj: str):
"""
Fetches the userstory metadata from Taiga.
Used as a fallback when task payloads do not include the nested userstory state.
"""
if not project_id or not userstory_id:
return {}

key = (project_id, userstory_id)
now = datetime.now(timezone.utc)
if key in _USERSTORY_CACHE and now - _USERSTORY_CACHE[key][0] < TTL:
return _USERSTORY_CACHE[key][1]

try:
headers = _build_taiga_headers(prj)
url = f"{TAIGA_API_URL}/userstories/{userstory_id}"
r = requests.get(
url, params={"project": project_id}, headers=headers, timeout=(1, 5)
)
r.raise_for_status()
except TAIGA_LOOKUP_ERRORS as exc:
logger.warning(
"Failed to fetch user story details for project %s user story %s: %s",
project_id,
userstory_id,
exc,
)
return {}

js = r.json()
details = {
"userstory_is_closed": (js.get("status_extra_info") or {}).get("is_closed"),
}
_USERSTORY_CACHE[key] = (now, details)
return details

def milestone_stats(project_id: str, milestone_id: str, prj: str):
"""
Fetches the statistics of a milestone in a Taiga project.
Expand All @@ -40,56 +140,26 @@ def milestone_stats(project_id: str, milestone_id: str, prj: str):
if key in _CACHE and now - _CACHE[key][0] < TTL:
return _CACHE[key][1]

user = resolve(prj, "taiga_user")
psw = resolve(prj, "taiga_password")
logger.debug(
"Resolving Taiga credentials for project %s: user=%s, password=%s",
prj,
"****" if user else None,
"****" if psw else None,
)
if user and 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,
)
stats = _empty_stats()
_CACHE[key] = (now, stats)
return stats

headers = {"Authorization": f"Bearer {token}"}
logger.debug("Using Taiga credentials for project %s", prj)
else:
headers = {}
logger.info("Using Taiga tunnel without authentication for project: %s", prj)

url = f"{TAIGA_API_URL}/milestones/{milestone_id}/stats"
logger.debug("Fetching Taiga milestone stats from URL: %s", url)
try:
headers = _build_taiga_headers(prj)
url = f"{TAIGA_API_URL}/milestones/{milestone_id}/stats"
r = requests.get(
url, params={"project": project_id}, headers=headers, timeout=MILESTONE_TIMEOUT
url, params={"project": project_id}, headers=headers, timeout=(1, 5)
)
r.raise_for_status()
except requests.exceptions.RequestException as exc:
except TAIGA_LOOKUP_ERRORS as exc:
logger.warning(
"Failed to fetch milestone stats for project %s milestone %s. "
"Returning empty milestone stats: %s",
prj,
"Failed to fetch milestone stats for project %s milestone %s: %s",
project_id,
milestone_id,
exc,
)
stats = _empty_stats()
_CACHE[key] = (now, stats)
return stats
return _empty_stats()

js = r.json()
stats = {
"milestone_total_points": sum(js.get("total_points", {}).values()),
"milestone_closed_points": sum(js.get("completed_points", 0)),
"milestone_total_points": sum((js.get("total_points") or {}).values()),
"milestone_closed_points": sum(js.get("completed_points") or []),
"milestone_total_userstories": js.get("total_userstories", 0),
"milestone_completed_userstories": js.get("completed_userstories", 0),
"milestone_total_tasks": js.get("total_tasks", 0),
Expand Down
80 changes: 42 additions & 38 deletions datasources/taiga_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import Dict
import re
from utils.pattern_detector import PatternDetector

from datasources.requests.taiga_api_call import milestone_stats
from utils.datetime_utils import to_madrid_local
Expand Down Expand Up @@ -141,36 +141,18 @@
is_closed = raw_payload.get("data", {}).get("status", {}).get("is_closed", "")
status = raw_payload.get("data", {}).get("status", {}).get("name", "")
created_date = to_madrid_local(raw_payload.get("data", {}).get("created_date", ""))
modified_date = to_madrid_local(
raw_payload.get("data", {}).get("modified_date", "")
)
finished_date = to_madrid_local(
raw_payload.get("data", {}).get("finished_date", "")
)
reference = raw_payload.get("data", {}).get("ref", "")
milestone_id = raw_payload.get("data", {}).get("milestone", {}).get("id", "")
milestone_name = raw_payload.get("data", {}).get("milestone", {}).get("name", "")
milestone_closed = (
raw_payload.get("data", {}).get("milestone", {}).get("closed", "")
)
milestone_created_date = (
raw_payload.get("data", {}).get("milestone", {}).get("created_date", "")
)
milestone_created_date = (
to_madrid_local(milestone_created_date) if milestone_created_date else ""
)
milestone_modified_date = (
raw_payload.get("data", {}).get("milestone", {}).get("modified_date", "")
)
milestone_modified_date = (
to_madrid_local(milestone_modified_date) if milestone_modified_date else ""
)
estimated_start = to_madrid_local(
raw_payload.get("data", {}).get("milestone", {}).get("estimated_start", "")
)
estimated_finish = to_madrid_local(
raw_payload.get("data", {}).get("milestone", {}).get("estimated_finish", "")
)
modified_date = to_madrid_local(raw_payload.get("data", {}).get("modified_date", ""))
finished_date = to_madrid_local(raw_payload.get("data", {}).get("finished_date", ""))
reference=raw_payload.get("data",{}).get("ref", "")
milestone_id=raw_payload.get("data",{}).get("milestone",{}).get("id", "")
milestone_name=raw_payload.get("data",{}).get("milestone",{}).get("name", "")
milestone_closed=bool(raw_payload.get("data",{}).get("milestone",{}).get("closed", False))
milestone_created_date=raw_payload.get("data",{}).get("milestone",{}).get("created_date", "")
milestone_created_date = to_madrid_local(milestone_created_date) if milestone_created_date else ""
milestone_modified_date=raw_payload.get("data",{}).get("milestone",{}).get("modified_date", "")
milestone_modified_date = to_madrid_local(milestone_modified_date) if milestone_modified_date else ""
estimated_start=to_madrid_local(raw_payload.get("data",{}).get("milestone",{}).get("estimated_start", ""))
estimated_finish=to_madrid_local(raw_payload.get("data",{}).get("milestone",{}).get("estimated_finish", ""))
assigned_by = raw_payload.get("by", {}).get("username", "")

milestone_data = milestone_stats(project_id, milestone_id, prj)
Expand Down Expand Up @@ -244,14 +226,36 @@
if custom_attributes is None:
custom_attributes = {}

description = raw_payload.get("data", {}).get("description", "")
# If the pattern "AS - A - I WANT - SO THAT" is used in the description, the vañue of pattern will be True, if not, it will be False
pattern = r"as\s+(.*?)\s+i want\s+(.*?)\s+so that\s+(.*)"
match = re.search(pattern, description, re.IGNORECASE)
if match:
pattern_in_description = True
description= raw_payload.get("data", {}).get("description", "")
# Detect BDD pattern (EN/ES/CA)
pattern_in_description = PatternDetector.detect_pattern(description)

# If the userstory has a milestone associated while created, we will get the values, if not, we will set them to None
if raw_payload.get("data",{}).get("milestone",{}) is not None:

milestone_id= raw_payload.get("data",{}).get("milestone",{}).get("id", "")
milestone_name= raw_payload.get("data",{}).get("milestone",{}).get("name", "")

Check warning on line 237 in datasources/taiga_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'milestone_name'; the value is never used.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ2meDrmVVB4PQRw6aVy&open=AZ2meDrmVVB4PQRw6aVy&pullRequest=19
milestone_closed= bool(raw_payload.get("data",{}).get("milestone",{}).get("closed", False))

Check warning on line 238 in datasources/taiga_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'milestone_closed'; the value is never used.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ2meDrmVVB4PQRw6aVx&open=AZ2meDrmVVB4PQRw6aVx&pullRequest=19
milestone_created_date= to_madrid_local(raw_payload.get("data",{}).get("milestone",{}).get("created_date", ""))

Check warning on line 239 in datasources/taiga_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'milestone_created_date'; the value is never used.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ2meDrmVVB4PQRw6aVw&open=AZ2meDrmVVB4PQRw6aVw&pullRequest=19
milestone_modified_date= to_madrid_local(raw_payload.get("data",{}).get("milestone",{}).get("modified_date", ""))
milestone_modified_date = to_madrid_local(milestone_modified_date) if milestone_modified_date else ""
estimated_start= to_madrid_local(raw_payload.get("data",{}).get("milestone",{}).get("estimated_start", ""))

Check warning on line 242 in datasources/taiga_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'estimated_start'; the value is never used.

See more on https://sonarcloud.io/project/issues?id=Learning-Dashboard_LD_Connect_Event&issues=AZ2meDrmVVB4PQRw6aVv&open=AZ2meDrmVVB4PQRw6aVv&pullRequest=19
estimated_finish= to_madrid_local(raw_payload.get("data",{}).get("milestone",{}).get("estimated_finish", ""))

Check warning on line 243 in datasources/taiga_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'estimated_finish'; the value is never used.

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

milestone_data= milestone_stats(project_id, milestone_id, prj)

Check warning on line 245 in datasources/taiga_handler.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'milestone_data'; the value is never used.

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



else:
pattern_in_description = False
milestone_id= ""
milestone_name= ""
milestone_closed= False
milestone_created_date= ""
milestone_modified_date= ""
estimated_start= ""
estimated_finish= ""
milestone_data = {}


# If the userstory has a milestone associated while created, we will get the values, if not, we will set them to None
if raw_payload.get("data", {}).get("milestone", {}) is not None:
Expand Down
47 changes: 47 additions & 0 deletions utils/pattern_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import re

class PatternDetector:
"""
Centralizado detector de patrones de User Stories multilenguaje.
Soporta: EN, ES, CA con sus variantes.
"""

# Patrones regex compilados para optimización
PATTERNS = [
# English
r"\bas\s+[\w\s]+\s+i\s+want\s+[\w\s,.:;!?-]+\s+so\s+that\s+[\w\s,.:;!?-]+",
r"\bas\s+[\w\s]+\s+i\s+want\s+[\w\s,.:;!?-]+\s+to\s+[\w\s,.:;!?-]+",

# Spanish - COMO...QUIERO...
r"\bcomo\s+[\w\s]+\s+quiero\s+[\w\s,.:;!?-]+\s+(?:de\s+manera\s+que|de\s+forma\s+que|para|por|porqu[eé]|porque)\s+[\w\s,.:;!?-]+",

# Catalan - COM...VULL...
r"\bcom\s+[\w\s]+\s+vull\s+[\w\s,.:;!?-]+\s+(?:de\s+manera\s+que|de\s+forma\s+que|per|perqu[eè]|perqué)\s+[\w\s,.:;!?-]+",
]

# Compilar patrones una sola vez
_compiled_patterns = [re.compile(p, re.IGNORECASE) for p in PATTERNS]

@classmethod
def detect_pattern(cls, description: str) -> bool:
"""
Detecta si una descripción contiene alguno de los patrones BDD soportados.

Args:
description: Texto de descripción de user story

Returns:
bool: True si contiene patrón válido, False en caso contrario
"""
if not description or not isinstance(description, str):
return False

# Normalizar: eliminar saltos de línea excesivos, mantener separadores
normalized = ' '.join(description.split())

# Probar cada patrón
for pattern in cls._compiled_patterns:
if pattern.search(normalized):
return True

return False
Loading
Loading