新增 Jira 渠道扩展,支持在 Yuxi 平台中集成 Jira 项目管理渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息与工单管理 - streaming: 流式消息处理 - parse: Jira 内容解析 - format: 消息格式转换 - agent_tools: Agent 工具集成 - security: 安全校验 - dedupe: 消息去重 - loopbreaker: 循环响应防护 - monitor: 渠道状态监控 - status: 会话与工单状态管理 - types: 类型定义
233 lines
9.1 KiB
Python
233 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.jira.dedupe import get_deduplicator
|
|
from yuxi.channel.extensions.jira.loopbreaker import JiraLoopBreaker
|
|
from yuxi.channel.extensions.jira.monitor import JiraMonitor
|
|
from yuxi.channel.gateway.routes import webhook_registry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class JiraGateway:
|
|
def __init__(self, outbound=None, config_adapter=None, security=None):
|
|
self._config_adapter = config_adapter
|
|
self._security = security
|
|
self._http: httpx.AsyncClient | None = None
|
|
self._account: dict = {}
|
|
self._loopbreaker = JiraLoopBreaker()
|
|
self._outbound = outbound
|
|
self._monitor: JiraMonitor | None = None
|
|
self._dispatch_queue: asyncio.Queue | None = None
|
|
self._running = False
|
|
self._webhook_id: str | None = None
|
|
|
|
async def start(self, ctx) -> dict:
|
|
account_id = getattr(ctx, "account_id", "default")
|
|
config = getattr(ctx, "config", {})
|
|
if self._config_adapter:
|
|
self._config_adapter.list_account_ids(config)
|
|
account = await self._config_adapter.resolve_account(account_id)
|
|
else:
|
|
account = {}
|
|
self._account = account
|
|
|
|
if not account.get("site_url") or not account.get("email"):
|
|
logger.warning("Jira account %s not configured, skipping start", account_id)
|
|
return {"running": False, "reason": "not-configured"}
|
|
|
|
credentials = f"{account['email']}:{account['api_token']}"
|
|
auth = f"Basic {base64.b64encode(credentials.encode('utf-8')).decode('utf-8')}"
|
|
|
|
self._http = httpx.AsyncClient(
|
|
base_url=account["site_url"].rstrip("/"),
|
|
headers={"Authorization": auth, "Accept": "application/json"},
|
|
timeout=httpx.Timeout(30.0),
|
|
)
|
|
|
|
if self._security:
|
|
allowed_projects = account.get("allowed_projects", [])
|
|
project_key = account.get("project_key", "")
|
|
if project_key and project_key not in allowed_projects:
|
|
allowed_projects.append(project_key)
|
|
self._security.configure(allowed_projects)
|
|
|
|
self._monitor = JiraMonitor(account)
|
|
self._dispatch_queue = asyncio.Queue(maxsize=1000)
|
|
self._running = True
|
|
|
|
self._webhook_id = await self._ensure_webhook_registered(account)
|
|
if self._webhook_id:
|
|
account["webhook_id"] = str(self._webhook_id)
|
|
|
|
webhook_registry.register(
|
|
"jira",
|
|
self._handle_webhook,
|
|
guard_config=None,
|
|
)
|
|
|
|
logger.info(
|
|
"Jira gateway started for account %s (webhook_id=%s)",
|
|
account_id,
|
|
self._webhook_id,
|
|
)
|
|
return {
|
|
"running": True,
|
|
"account_id": account_id,
|
|
"queue": self._dispatch_queue,
|
|
"webhook_id": self._webhook_id,
|
|
}
|
|
|
|
async def stop(self, ctx) -> None:
|
|
self._running = False
|
|
if self._webhook_id and self._http:
|
|
try:
|
|
await self._http.delete(f"/rest/api/3/webhook/{self._webhook_id}")
|
|
except Exception:
|
|
logger.exception("Jira webhook cleanup failed")
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|
|
self._webhook_id = None
|
|
self._dispatch_queue = None
|
|
logger.info("Jira gateway stopped")
|
|
|
|
async def _handle_webhook(self, payload: dict, headers: dict | None = None) -> dict:
|
|
webhook_event = payload.get("webhookEvent", "")
|
|
comment = payload.get("comment", {})
|
|
issue = payload.get("issue", {})
|
|
issue_key = issue.get("key", "")
|
|
|
|
webhook_secret = self._account.get("webhook_secret", "")
|
|
if webhook_secret and headers:
|
|
signature_header = headers.get("x-hub-signature", "")
|
|
if signature_header:
|
|
body_bytes = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
expected = hmac.new(
|
|
webhook_secret.encode("utf-8"),
|
|
body_bytes,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
received = signature_header.replace("sha256=", "")
|
|
if not hmac.compare_digest(expected, received):
|
|
logger.warning("Jira webhook signature mismatch")
|
|
return {"status": "rejected", "reason": "invalid_signature"}
|
|
|
|
webhook_id_header = headers.get("x-atlassian-webhook-identifier", "") if headers else ""
|
|
comment_id = str(comment.get("id", "")) if comment else ""
|
|
dedupe_key = f"{webhook_event}:{issue_key}:{comment_id}" if comment_id else f"{webhook_event}:{issue_key}"
|
|
if webhook_id_header:
|
|
dedupe_key = f"{webhook_id_header}:{dedupe_key}"
|
|
|
|
deduplicator = get_deduplicator(self._account.get("account_id", "default"))
|
|
if deduplicator.is_duplicate(dedupe_key):
|
|
return {"status": "skipped", "reason": "duplicate"}
|
|
|
|
properties = comment.get("properties", []) if comment else []
|
|
for prop in properties:
|
|
if prop.get("key") == "ai.agent":
|
|
return {"status": "skipped", "reason": "ai_generated_comment"}
|
|
|
|
author = comment.get("author", {}) if comment else payload.get("user", {})
|
|
bot_account_id = self._account.get("bot_account_id", "")
|
|
if bot_account_id and author.get("accountId") == bot_account_id:
|
|
return {"status": "skipped", "reason": "self_authored"}
|
|
|
|
changelog = payload.get("changelog", {})
|
|
if self._security and self._security.is_self_changelog(changelog.get("items", []), bot_account_id):
|
|
return {"status": "skipped", "reason": "self_changelog"}
|
|
|
|
fields = issue.get("fields", {})
|
|
project_key = fields.get("project", {}).get("key", "")
|
|
if self._security and not self._security.is_project_allowed(project_key):
|
|
return {"status": "skipped", "reason": "project_not_allowed"}
|
|
|
|
if not self._loopbreaker.record_and_check(issue_key):
|
|
return {"status": "skipped", "reason": "loop_breaker_tripped"}
|
|
|
|
if not self._monitor:
|
|
return {"status": "error", "reason": "monitor_not_initialized"}
|
|
|
|
unified = self._monitor.convert_webhook_to_unified(payload, self._account.get("account_id", "default"))
|
|
if not unified:
|
|
return {"status": "ignored", "reason": "unrecognized_event_type"}
|
|
|
|
if self._dispatch_queue:
|
|
try:
|
|
self._dispatch_queue.put_nowait(unified)
|
|
except asyncio.QueueFull:
|
|
logger.warning("Jira dispatch queue full, dropping event %s", dedupe_key)
|
|
|
|
return {"status": "ok"}
|
|
|
|
async def _ensure_webhook_registered(self, account: dict) -> str | None:
|
|
callback_base = account.get("callback_base_url", "")
|
|
webhook_url = f"{callback_base.rstrip('/')}/webhook/jira"
|
|
|
|
try:
|
|
existing = await self._http.get("/rest/api/3/webhook")
|
|
if existing.status_code == 200:
|
|
webhooks = existing.json()
|
|
for wh in webhooks:
|
|
if wh.get("url") == webhook_url:
|
|
return wh.get("id")
|
|
except Exception:
|
|
logger.exception("Failed to list existing webhooks")
|
|
|
|
jql = account.get("jql_filter", "") or f"project = {account.get('project_key', '')}"
|
|
|
|
try:
|
|
result = await self._http.post(
|
|
"/rest/api/3/webhook",
|
|
json={
|
|
"name": f"ForcePilot-{account.get('account_id', 'default')}",
|
|
"url": webhook_url,
|
|
"events": [
|
|
"comment_created",
|
|
"comment_updated",
|
|
"comment_deleted",
|
|
"jira:issue_created",
|
|
"jira:issue_updated",
|
|
"jira:issue_deleted",
|
|
"attachment_created",
|
|
"attachment_deleted",
|
|
],
|
|
"filters": {
|
|
"issue-related-events-section-filter": jql,
|
|
}
|
|
if jql
|
|
else None,
|
|
"enabled": True,
|
|
"excludeBody": False,
|
|
},
|
|
)
|
|
if result.status_code in (200, 201):
|
|
data = result.json()
|
|
if isinstance(data, dict):
|
|
results = data.get("webhookRegistrationResult", [])
|
|
if results:
|
|
wh_id = results[0].get("createdWebhookId") or results[0].get("id")
|
|
return str(wh_id)
|
|
if isinstance(data, list) and data:
|
|
return str(data[0].get("id", ""))
|
|
return str(data.get("id", ""))
|
|
except Exception:
|
|
logger.exception("Failed to register Jira webhook")
|
|
return None
|
|
|
|
@property
|
|
def dispatch_queue(self):
|
|
return self._dispatch_queue
|
|
|
|
@property
|
|
def account(self):
|
|
return self._account
|