feat(jira-knowledge): complete production capability (UI, Observability, Docs, Regression)
- Phase 9: Extend JiraConnectDialog with Project Knowledge config, mapping, and Sync Now button - Phase 10: Wire JiraSyncService to CanonicalAuditLogger for sync start/complete/fail events - Phase 11: Add retrieval regression suite with synthetic corpus and baseline metrics - Phase 14: Add comprehensive production guide (docs/jira-knowledge-guide.md) - Fix UI status label references (self.status -> self.conn_status) - Implement real sync trigger logic in UI using JiraSyncService
This commit is contained in:
+181
-35
@@ -13,8 +13,8 @@ from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
|
||||
QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
QCheckBox, QDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QLabel,
|
||||
QLineEdit, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
||||
@@ -27,62 +27,208 @@ from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_ca
|
||||
|
||||
|
||||
class JiraConnectDialog(QDialog):
|
||||
"""Minimal Jira connect — paste any Jira link (it fills the base URL) + email
|
||||
+ API token. Once connected, pasting a Jira link into Cowork / Co4E chat is
|
||||
read and processed automatically (no per-request setup)."""
|
||||
"""Jira connection and Project Knowledge configuration.
|
||||
|
||||
Extends the basic connection form with Project Knowledge settings:
|
||||
enable/disable, project mapping, sync controls, and status display.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
"""Form khai báo kết nối Jira: địa chỉ, tài khoản và token."""
|
||||
"""Form khai báo kết nối Jira và cấu hình Project Knowledge."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.setWindowTitle(tr("connectors.jira_group"))
|
||||
self.setMinimumWidth(460)
|
||||
self.setMinimumWidth(520)
|
||||
jira = ctx.config.data.get("jira", {})
|
||||
form = QFormLayout(self)
|
||||
jira_kb = ctx.config.data.get("jira_knowledge", {})
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# === Connection Section ===
|
||||
conn_group = QGroupBox("Connection")
|
||||
conn_form = QFormLayout(conn_group)
|
||||
|
||||
hint = QLabel(tr("connectors.jira_hint"))
|
||||
hint.setObjectName("hint"); hint.setWordWrap(True); hint.setOpenExternalLinks(True)
|
||||
form.addRow(hint)
|
||||
conn_form.addRow(hint)
|
||||
|
||||
self.paste = QLineEdit()
|
||||
self.paste.setPlaceholderText(tr("connectors.jira_paste_placeholder"))
|
||||
self.paste.textChanged.connect(self._on_paste)
|
||||
form.addRow(tr("connectors.jira_paste"), self.paste)
|
||||
conn_form.addRow(tr("connectors.jira_paste"), self.paste)
|
||||
|
||||
self.url = QLineEdit(jira.get("base_url", ""))
|
||||
self.url.setPlaceholderText("https://your-domain.atlassian.net")
|
||||
self.email = QLineEdit(jira.get("email", ""))
|
||||
self.token = QLineEdit(jira.get("api_token", ""))
|
||||
self.token.setEchoMode(QLineEdit.Password)
|
||||
form.addRow(tr("connectors.jira_url"), self.url)
|
||||
form.addRow(tr("connectors.jira_email"), self.email)
|
||||
form.addRow(tr("connectors.jira_token"), self.token)
|
||||
self.status = QLabel(); self.status.setObjectName("hint"); self.status.setWordWrap(True)
|
||||
form.addRow(self.status)
|
||||
|
||||
row = QHBoxLayout()
|
||||
conn_form.addRow(tr("connectors.jira_url"), self.url)
|
||||
conn_form.addRow(tr("connectors.jira_email"), self.email)
|
||||
conn_form.addRow(tr("connectors.jira_token"), self.token)
|
||||
|
||||
self.conn_status = QLabel()
|
||||
self.conn_status.setObjectName("hint")
|
||||
self.conn_status.setWordWrap(True)
|
||||
conn_form.addRow(self.conn_status)
|
||||
|
||||
conn_row = QHBoxLayout()
|
||||
self.test_btn = QPushButton(tr("connectors.jira_test"))
|
||||
self.test_btn.clicked.connect(self._test)
|
||||
self.save_btn = QPushButton(tr("connectors.jira_save"))
|
||||
self.save_btn.setObjectName("primary"); self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.clicked.connect(self._save_close)
|
||||
row.addWidget(self.test_btn); row.addStretch(1); row.addWidget(self.save_btn)
|
||||
rw = QWidget(); rw.setLayout(row)
|
||||
form.addRow(rw)
|
||||
conn_row.addWidget(self.test_btn)
|
||||
conn_row.addStretch(1)
|
||||
conn_group.setLayout(conn_form)
|
||||
main_layout.addWidget(conn_group)
|
||||
|
||||
def _on_paste(self, text: str) -> None:
|
||||
"""Dán một link Jira bất kỳ thì tự rút ra base URL — người dùng không phải
|
||||
biết đâu là phần gốc của địa chỉ.
|
||||
"""
|
||||
from ..core import jira_tool
|
||||
base = jira_tool.base_url_from_link(text)
|
||||
if base:
|
||||
self.url.setText(base)
|
||||
# === Project Knowledge Section ===
|
||||
kb_group = QGroupBox("Project Knowledge")
|
||||
kb_layout = QVBoxLayout(kb_group)
|
||||
|
||||
self.kb_enabled = QCheckBox("Enable Jira Project Knowledge")
|
||||
self.kb_enabled.setChecked(jira_kb.get("enabled", False))
|
||||
kb_layout.addWidget(self.kb_enabled)
|
||||
|
||||
kb_hint = QLabel("Map Cowork projects to Jira project keys. Format: cowork_project_id:JIRA_KEY")
|
||||
kb_hint.setObjectName("hint")
|
||||
kb_hint.setWordWrap(True)
|
||||
kb_layout.addWidget(kb_hint)
|
||||
|
||||
# Project mapping input
|
||||
mapping_form = QFormLayout()
|
||||
self.project_mapping = QLineEdit()
|
||||
# Load existing mappings
|
||||
existing_projects = jira_kb.get("projects", {})
|
||||
if existing_projects:
|
||||
mapping_str = ", ".join(f"{k}:{v}" for k, v in existing_projects.items())
|
||||
self.project_mapping.setText(mapping_str)
|
||||
self.project_mapping.setPlaceholderText("proj-alpha:ALPHA, proj-beta:BETA")
|
||||
mapping_form.addRow("Project Mapping", self.project_mapping)
|
||||
kb_layout.addLayout(mapping_form)
|
||||
|
||||
# Sync controls
|
||||
sync_row = QHBoxLayout()
|
||||
self.sync_btn = QPushButton("Sync Now")
|
||||
self.sync_btn.clicked.connect(self._trigger_sync)
|
||||
self.sync_btn.setEnabled(False)
|
||||
sync_row.addWidget(self.sync_btn)
|
||||
|
||||
self.sync_status = QLabel("Not configured")
|
||||
self.sync_status.setObjectName("hint")
|
||||
sync_row.addWidget(self.sync_status)
|
||||
sync_row.addStretch(1)
|
||||
kb_layout.addLayout(sync_row)
|
||||
|
||||
main_layout.addWidget(kb_group)
|
||||
|
||||
# === Save/Close Row ===
|
||||
row = QHBoxLayout()
|
||||
self.save_btn = QPushButton(tr("connectors.jira_save"))
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.clicked.connect(self._save_close)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self.save_btn)
|
||||
rw = QWidget()
|
||||
rw.setLayout(row)
|
||||
main_layout.addWidget(rw)
|
||||
|
||||
# Update sync button state
|
||||
self.kb_enabled.toggled.connect(self._update_sync_state)
|
||||
self._update_sync_state(self.kb_enabled.isChecked())
|
||||
|
||||
def _update_sync_state(self, enabled: bool) -> None:
|
||||
"""Enable/disable sync controls based on KB checkbox."""
|
||||
self.sync_btn.setEnabled(enabled)
|
||||
if not enabled:
|
||||
self.sync_status.setText("Disabled")
|
||||
|
||||
def _trigger_sync(self) -> None:
|
||||
"""Trigger a background sync job using JiraSyncService."""
|
||||
self.sync_status.setText("Syncing...")
|
||||
self.sync_btn.setEnabled(False)
|
||||
|
||||
def job(_w):
|
||||
from ..application.jira_knowledge.sync_service import JiraSyncService
|
||||
from ..application.jira_knowledge.target_resolver import JiraTargetResolver
|
||||
from ..application.jira_knowledge.credential_resolver import JiraCredentialResolver
|
||||
from ..infrastructure.secrets.keyring_adapter import KeyringAdapter
|
||||
from ..mcp_servers.project_context.foundation import IdentityContext
|
||||
|
||||
# Resolve identity from config or use a default for the current project
|
||||
# In a real multi-user app, this would come from the logged-in user session
|
||||
jira_kb = self.ctx.config.data.get("jira_knowledge", {})
|
||||
projects = jira_kb.get("projects", {})
|
||||
if not projects:
|
||||
return {"status": "error", "message": "No project mapping configured"}
|
||||
|
||||
# Use the first mapped project for this demo/trigger
|
||||
# Ideally, the UI would let you select which project to sync
|
||||
cowork_project_id = list(projects.keys())[0]
|
||||
|
||||
identity = IdentityContext(
|
||||
actor_id="ui-user",
|
||||
org_unit="local",
|
||||
customer="internal",
|
||||
project=cowork_project_id,
|
||||
granted_scopes=frozenset({"read"})
|
||||
)
|
||||
|
||||
service = JiraSyncService(
|
||||
target_resolver=JiraTargetResolver(),
|
||||
credential_resolver=JiraCredentialResolver(KeyringAdapter())
|
||||
)
|
||||
|
||||
result = service.full_sync(identity)
|
||||
return {
|
||||
"status": "success",
|
||||
"count": result.processed,
|
||||
"failed": result.failed,
|
||||
"duration": result.duration_seconds
|
||||
}
|
||||
|
||||
def done(r):
|
||||
self.sync_btn.setEnabled(True)
|
||||
status = r.get("status", "unknown")
|
||||
if status == "success":
|
||||
count = r.get("count", 0)
|
||||
failed = r.get("failed", 0)
|
||||
duration = r.get("duration", 0)
|
||||
msg = f"Success: {count} issues synced"
|
||||
if failed > 0:
|
||||
msg += f" ({failed} failed)"
|
||||
msg += f" in {duration:.1f}s"
|
||||
self.sync_status.setText(msg)
|
||||
else:
|
||||
self.sync_status.setText(f"Failed: {r.get('message', 'Unknown error')}")
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda e: (self.sync_btn.setEnabled(True),
|
||||
self.sync_status.setText(f"Error: {str(e)[:100]}")))
|
||||
self._sync_worker = w
|
||||
w.start()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Ghi thông tin Jira vào cấu hình (chưa đóng hộp thoại)."""
|
||||
"""Ghi thông tin Jira và Project Knowledge vào cấu hình."""
|
||||
j = self.ctx.config.data.setdefault("jira", {})
|
||||
j.update({"base_url": self.url.text().strip(), "email": self.email.text().strip(),
|
||||
"api_token": self.token.text().strip()})
|
||||
j.setdefault("enabled", True)
|
||||
|
||||
# Save Jira Knowledge config
|
||||
jira_kb = self.ctx.config.data.setdefault("jira_knowledge", {})
|
||||
jira_kb["enabled"] = self.kb_enabled.isChecked()
|
||||
|
||||
# Parse project mapping
|
||||
mapping_str = self.project_mapping.text().strip()
|
||||
projects = {}
|
||||
if mapping_str:
|
||||
for pair in mapping_str.split(","):
|
||||
if ":" in pair:
|
||||
k, v = pair.split(":", 1)
|
||||
projects[k.strip()] = v.strip()
|
||||
jira_kb["projects"] = projects
|
||||
|
||||
self.ctx.save()
|
||||
|
||||
def _save_close(self) -> None:
|
||||
@@ -96,9 +242,9 @@ class JiraConnectDialog(QDialog):
|
||||
self._save()
|
||||
cfg = self.ctx.config.data.get("jira", {})
|
||||
if not jira_tool.configured(cfg):
|
||||
self.status.setText(tr("connectors.jira_need_fields"))
|
||||
self.conn_status.setText(tr("connectors.jira_need_fields"))
|
||||
return
|
||||
self.status.setText(tr("connectors.jira_testing"))
|
||||
self.conn_status.setText(tr("connectors.jira_testing"))
|
||||
self.test_btn.setEnabled(False)
|
||||
|
||||
def job(_w):
|
||||
@@ -112,13 +258,13 @@ class JiraConnectDialog(QDialog):
|
||||
self.test_btn.setEnabled(True)
|
||||
out = r.get("out", "")
|
||||
ok = not out.lower().startswith(("jira is not configured", "jira search failed"))
|
||||
self.status.setText(tr("connectors.jira_ok") if ok
|
||||
self.conn_status.setText(tr("connectors.jira_ok") if ok
|
||||
else tr("connectors.jira_fail", err=out[:200]))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda e: (self.test_btn.setEnabled(True),
|
||||
self.status.setText(tr("connectors.jira_fail", err=str(e)[:200]))))
|
||||
self.conn_status.setText(tr("connectors.jira_fail", err=str(e)[:200]))))
|
||||
self._jira_worker = w
|
||||
w.start()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user