206 lines
6.1 KiB
Python
206 lines
6.1 KiB
Python
"""
|
|
Jira webhook endpoint for receiving issue change notifications.
|
|
|
|
Handles incoming webhooks from Atlassian Jira, verifies HMAC signatures,
|
|
and triggers issue data fetching.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from flask import Blueprint, abort, jsonify, request
|
|
|
|
from .service import Config, get_jira_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
jira_bp = Blueprint("jira", __name__, url_prefix="/webhooks")
|
|
|
|
# Path for storing raw webhook events (for debugging/audit)
|
|
WEBHOOK_LOG_DIR = Config.JIRA_DATA_DIR / "webhook_events"
|
|
|
|
|
|
def verify_signature(payload: bytes, signature: str | None) -> bool:
|
|
"""
|
|
Verify HMAC-SHA256 signature from Jira webhook.
|
|
|
|
Args:
|
|
payload: Raw request body bytes
|
|
signature: Signature from X-Hub-Signature header
|
|
|
|
Returns:
|
|
True if signature is valid or if no secret is configured (dev mode)
|
|
"""
|
|
secret = Config.JIRA_WEBHOOK_SECRET
|
|
|
|
# If no secret configured, skip verification (not recommended for production)
|
|
if not secret:
|
|
logger.warning("JIRA_WEBHOOK_SECRET not configured, skipping signature verification")
|
|
return True
|
|
|
|
if not signature:
|
|
logger.warning("No signature provided in webhook request")
|
|
return False
|
|
|
|
# Jira may send signature with or without algorithm prefix
|
|
if signature.startswith("sha256="):
|
|
signature = signature[7:]
|
|
|
|
# Compute expected signature
|
|
expected = hmac.new(
|
|
secret.encode("utf-8"),
|
|
payload,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
# Constant-time comparison to prevent timing attacks
|
|
return hmac.compare_digest(signature, expected)
|
|
|
|
|
|
def log_webhook_event(event_data: dict) -> None:
|
|
"""
|
|
Log webhook event to file for debugging/audit.
|
|
|
|
Args:
|
|
event_data: Webhook payload
|
|
"""
|
|
try:
|
|
WEBHOOK_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
|
|
event_type = event_data.get("webhookEvent", "unknown").replace(":", "_")
|
|
filename = f"{timestamp}_{event_type}.json"
|
|
filepath = WEBHOOK_LOG_DIR / filename
|
|
|
|
with open(filepath, "w") as f:
|
|
json.dump(event_data, f, indent=2, default=str)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to log webhook event: {e}")
|
|
|
|
|
|
@jira_bp.route("/jira", methods=["POST"])
|
|
def receive_jira_webhook():
|
|
"""
|
|
Receive and process Jira webhook notifications.
|
|
|
|
Jira sends POST requests with JSON payload containing:
|
|
- webhookEvent: Event type (e.g., "jira:issue_created", "jira:issue_updated")
|
|
- issue: Issue data (may be partial)
|
|
- comment: Comment data (for comment events)
|
|
- changelog: List of field changes (for update events)
|
|
|
|
Returns:
|
|
JSON response with processing status
|
|
"""
|
|
# Get raw payload for signature verification
|
|
payload = request.get_data()
|
|
|
|
# Verify signature (Jira uses X-Hub-Signature or X-Hub-Signature-256)
|
|
signature = request.headers.get("X-Hub-Signature-256") or request.headers.get("X-Hub-Signature")
|
|
|
|
if not verify_signature(payload, signature):
|
|
logger.warning(f"Invalid webhook signature from {request.remote_addr}")
|
|
abort(401, "Invalid signature")
|
|
|
|
# Parse JSON payload
|
|
try:
|
|
event_data = request.get_json(force=True)
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse webhook JSON: {e}")
|
|
abort(400, "Invalid JSON payload")
|
|
|
|
if not event_data:
|
|
abort(400, "Empty payload")
|
|
|
|
# Log the event for debugging
|
|
log_webhook_event(event_data)
|
|
|
|
# Extract event info
|
|
webhook_event = event_data.get("webhookEvent", "unknown")
|
|
issue = event_data.get("issue", {})
|
|
issue_key = issue.get("key", "unknown")
|
|
|
|
logger.info(f"Received webhook: {webhook_event} for issue {issue_key}")
|
|
|
|
# Process the event
|
|
jira_service = get_jira_service()
|
|
|
|
if not jira_service.is_configured():
|
|
logger.error("Jira service not configured, cannot process webhook")
|
|
return jsonify({
|
|
"status": "error",
|
|
"message": "Jira service not configured",
|
|
}), 503
|
|
|
|
# Process asynchronously would be better, but for now process synchronously
|
|
success = jira_service.process_webhook_event(event_data)
|
|
|
|
if success:
|
|
return jsonify({
|
|
"status": "ok",
|
|
"event": webhook_event,
|
|
"issue": issue_key,
|
|
})
|
|
else:
|
|
return jsonify({
|
|
"status": "error",
|
|
"message": "Failed to process event",
|
|
"event": webhook_event,
|
|
"issue": issue_key,
|
|
}), 500
|
|
|
|
|
|
@jira_bp.route("/jira/health", methods=["GET"])
|
|
def jira_webhook_health():
|
|
"""
|
|
Health check for Jira webhook endpoint.
|
|
|
|
Returns configuration status without exposing secrets.
|
|
"""
|
|
jira_service = get_jira_service()
|
|
|
|
return jsonify({
|
|
"status": "ok",
|
|
"configured": jira_service.is_configured(),
|
|
"webhook_secret_set": bool(Config.JIRA_WEBHOOK_SECRET),
|
|
"jira_domain": Config.JIRA_DOMAIN or "(not set)",
|
|
})
|
|
|
|
|
|
@jira_bp.route("/jira/test", methods=["POST"])
|
|
def test_jira_fetch():
|
|
"""
|
|
Test endpoint to manually fetch an issue (for debugging).
|
|
|
|
Requires JSON body: {"issue_key": "KSP-123"}
|
|
Only available if FLASK_DEBUG is true.
|
|
"""
|
|
if not Config.DEBUG:
|
|
abort(404)
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
issue_key = data.get("issue_key")
|
|
|
|
if not issue_key:
|
|
return jsonify({"error": "issue_key is required"}), 400
|
|
|
|
jira_service = get_jira_service()
|
|
|
|
if not jira_service.is_configured():
|
|
return jsonify({"error": "Jira service not configured"}), 503
|
|
|
|
issue_data = jira_service.fetch_issue(issue_key)
|
|
|
|
if issue_data:
|
|
saved_path = jira_service.save_issue(issue_data)
|
|
return jsonify({
|
|
"status": "ok",
|
|
"issue_key": issue_key,
|
|
"saved_to": str(saved_path) if saved_path else None,
|
|
"fields_count": len(issue_data.get("fields", {})),
|
|
})
|
|
else:
|
|
return jsonify({"error": f"Failed to fetch issue {issue_key}"}), 500
|