diff --git a/application/jira_knowledge/sync_service.py b/application/jira_knowledge/sync_service.py index f369864..11d9f68 100644 --- a/application/jira_knowledge/sync_service.py +++ b/application/jira_knowledge/sync_service.py @@ -90,6 +90,21 @@ class JiraSyncService: manifest.mark_attempt() save_manifest(self._index._root, manifest) + # Emit audit event for sync start + try: + from ...infrastructure.telemetry.audit_logger import CanonicalAuditLogger + from ...config import CONFIG_DIR + logger = CanonicalAuditLogger(CONFIG_DIR / "audit") + logger.record( + kind="jira_knowledge.sync.started", + name=f"{target.cowork_project_id}:{target.jira_project_key}", + ok=True, + detail=f"mode={'incremental' if incremental else 'full'}", + agent_role="system" + ) + except Exception: + pass # Audit failure must not break sync + # Fall back to full sync when no cursor exists. if incremental and not manifest.sync_cursor: incremental = False @@ -127,6 +142,22 @@ class JiraSyncService: total_indexed=total_indexed, ) save_manifest(self._index._root, manifest) + + # Emit audit event for sync success + try: + from ...infrastructure.telemetry.audit_logger import CanonicalAuditLogger + from ...config import CONFIG_DIR + logger = CanonicalAuditLogger(CONFIG_DIR / "audit") + logger.record( + kind="jira_knowledge.sync.completed", + name=f"{target.cowork_project_id}:{target.jira_project_key}", + ok=True, + detail=f"processed={processed},failed={failed},duration={duration:.2f}s", + agent_role="system" + ) + except Exception: + pass + return SyncResult( processed=processed, failed=failed, @@ -138,11 +169,43 @@ class JiraSyncService: duration = time.monotonic() - start manifest.mark_failure(category=exc.code, failed=0) save_manifest(self._index._root, manifest) + + # Emit audit event for sync failure + try: + from ...infrastructure.telemetry.audit_logger import CanonicalAuditLogger + from ...config import CONFIG_DIR + logger = CanonicalAuditLogger(CONFIG_DIR / "audit") + logger.record( + kind="jira_knowledge.sync.failed", + name=f"{target.cowork_project_id}:{target.jira_project_key}", + ok=False, + detail=f"error={exc.code},message={exc.safe_message[:100]}", + agent_role="system" + ) + except Exception: + pass + raise except Exception as exc: # noqa: BLE001 duration = time.monotonic() - start manifest.mark_failure(category="UNEXPECTED", failed=0) save_manifest(self._index._root, manifest) + + # Emit audit event for unexpected failure + try: + from ...infrastructure.telemetry.audit_logger import CanonicalAuditLogger + from ...config import CONFIG_DIR + logger = CanonicalAuditLogger(CONFIG_DIR / "audit") + logger.record( + kind="jira_knowledge.sync.failed", + name=f"{target.cowork_project_id}:{target.jira_project_key}", + ok=False, + detail=f"error=UNEXPECTED,type={type(exc).__name__}", + agent_role="system" + ) + except Exception: + pass + raise ProviderError( "SYNC_FAILED", f"Jira sync failed: {type(exc).__name__}", diff --git a/docs/jira-knowledge-guide.md b/docs/jira-knowledge-guide.md new file mode 100644 index 0000000..e79d792 --- /dev/null +++ b/docs/jira-knowledge-guide.md @@ -0,0 +1,101 @@ +# Jira Project Knowledge - Production Guide + +This guide covers the setup, operation, and troubleshooting of the Jira Project Knowledge capability in Cowork Local. + +## 1. Architecture Overview + +Jira Project Knowledge enables Cowork to index Jira issues as searchable project knowledge. The flow is: + +1. **Configuration**: User maps a Cowork project to a Jira project key via UI. +2. **Sync**: `JiraSyncService` fetches issues from Jira using the configured credentials. +3. **Normalization**: Raw Jira JSON is converted to `CanonicalJiraIssue` (stripping markup, bounding content). +4. **Indexing**: Canonical issues are stored as atomic JSON files in `~/.cowork_local/jira_kb//issues/`. +5. **Retrieval**: `search_project_knowledge` MCP tool queries the local index using lexical scoring. + +## 2. Prerequisites + +* **Jira Access**: Read-only access to the target Jira project. +* **Credentials**: + * **Jira Cloud**: Email + API Token (from id.atlassian.com). + * **Jira Server/Data Center**: Personal Access Token (PAT) or Username/Password. +* **Python**: 3.10+ (for Pydantic v2 compatibility). + +## 3. Setup & Configuration + +### 3.1 Connect Jira +1. Open Cowork Local. +2. Go to **Monitoring** -> **Tools** -> **Jira**. +3. Enter **Base URL** (e.g., `https://your-domain.atlassian.net` or `https://jira.company.com`). +4. Enter **Email** (for Cloud) or **Username** (for Server). +5. Enter **API Token** or **PAT**. +6. Click **Test Connection**. + +### 3.2 Enable Project Knowledge +1. In the same Jira dialog, check **Enable Jira Project Knowledge**. +2. Enter **Project Mapping** in the format `cowork_project_id:JIRA_PROJECT_KEY`. + * Example: `proj-alpha:ALPHA, proj-beta:BETA` +3. Click **Save**. + +### 3.3 Initial Sync +1. Click **Sync Now**. +2. Wait for the status to update to "Success: X issues synced". +3. The sync runs in the background; the UI remains responsive. + +## 4. Usage + +### 4.1 Search via Agent +Ask the agent questions about the project requirements or bugs. The agent will automatically use `search_project_knowledge` if Jira Knowledge is enabled for the current project. + +* *Example*: "What are the acceptance criteria for the login feature?" +* *Example*: "Find bugs related to database timeout." + +### 4.2 MCP Tool +The tool `search_project_knowledge` is available via the Project Context MCP server. + +* **Input**: `project_id`, `query`, `top_k` (optional). +* **Output**: Ranked list of excerpts with Jira source URLs. + +## 5. Security & Isolation + +* **Read-Only**: The connector never writes to Jira. +* **Project Isolation**: Knowledge is strictly scoped by `project_id`. A user with access to Project A cannot search Project B's knowledge, even if they guess the project ID. The target resolver enforces this structurally. +* **Credential Safety**: Credentials are stored in the OS Keyring (via `SecretStore`), not in plain text config files (unless fallback is used). They are never logged or sent to the LLM. +* **Untrusted Content**: Jira content is treated as untrusted. Prompt injection attempts in Jira descriptions are fenced and neutralized before reaching the agent context. + +## 6. Observability + +Sync operations emit audit events to `~/.cowork_local/audit/YYYY-MM-DD.jsonl`: + +* `jira_knowledge.sync.started`: Sync initiated. +* `jira_knowledge.sync.completed`: Sync finished successfully (includes counts/duration). +* `jira_knowledge.sync.failed`: Sync failed (includes error code). + +## 7. Troubleshooting + +### 403 Forbidden +* **Cause**: Invalid credentials or insufficient permissions. +* **Fix**: + * **Cloud**: Ensure you are using an API Token, not your password. + * **Server**: Ensure you are using a valid Personal Access Token (PAT). If PAT fails, try Basic Auth with your actual password (some older servers require this). + * Check that your user has "Browse Projects" permission for the target Jira project. + +### "Jira Project Knowledge is not configured" +* **Cause**: No project mapping found for the current identity. +* **Fix**: Ensure the `cowork_project_id` in the mapping matches the project selected in Cowork. + +### Sync Fails / Timeout +* **Cause**: Network issues or large project size. +* **Fix**: Check network connectivity to Jira. The sync has a timeout of 20s per request. For very large projects, the initial sync may take time; subsequent incremental syncs are faster. + +## 8. File Structure + +* `~/.cowork_local/config.json`: Stores `jira` connection settings and `jira_knowledge` mappings. +* `~/.cowork_local/jira_kb//issues/`: Indexed canonical issues (JSON). +* `~/.cowork_local/jira_kb//manifest.json`: Sync state (last sync time, cursor). +* `~/.cowork_local/audit/`: Audit logs. + +## 9. Known Limitations + +* **Lexical Search**: Current retrieval uses term-overlap scoring, not semantic embeddings. It works well for exact terms and keywords but may miss conceptual synonyms. +* **Manual Sync**: Incremental sync is not yet scheduled automatically; it must be triggered via "Sync Now" or CLI. +* **Rich Text**: Complex Jira rich text (ADF) is simplified to plain text placeholders. \ No newline at end of file diff --git a/tests/test_jira_knowledge_regression.py b/tests/test_jira_knowledge_regression.py new file mode 100644 index 0000000..90102b6 --- /dev/null +++ b/tests/test_jira_knowledge_regression.py @@ -0,0 +1,214 @@ +"""Retrieval regression suite for Jira Project Knowledge. + +This suite uses a synthetic Jira corpus to evaluate retrieval quality without +requiring a live Jira instance or confidential customer data. It covers: +- Exact term matching +- Paraphrasing +- Ambiguous queries +- Negative/no-result cases +- Cross-project isolation +- Citation completeness +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from cowork_local.application.jira_knowledge.index_repository import JiraKnowledgeIndex +from cowork_local.domain.jira_knowledge.canonical_issue import CanonicalJiraIssue, JiraProvenance +from cowork_local.mcp_servers.project_context.providers.jira_knowledge import JiraKnowledgeProvider, _JiraKbTarget + + +# --------------------------------------------------------------------------- +# Synthetic Corpus +# --------------------------------------------------------------------------- + +PROJECT_A = "proj-alpha" +PROJECT_B = "proj-beta" +JIRA_KEY_A = "ALPHA" +JIRA_KEY_B = "BETA" + +CORPUS = [ + # Project A: Requirements + CanonicalJiraIssue( + knowledge_id=f"{JIRA_KEY_A}/REQ-1", + project_id=PROJECT_A, + title="User Authentication Requirement", + content="The system must support user login via email and password. Account lockout occurs after 5 failed attempts.", + provenance=JiraProvenance(system="jira", issue_key="REQ-1", project_key=JIRA_KEY_A, source_url="http://jira/REQ-1", source_updated="2026-01-01T00:00:00Z", issue_type="Requirement", status="Done"), + ingested_at="2026-01-01T00:00:00Z" + ), + CanonicalJiraIssue( + knowledge_id=f"{JIRA_KEY_A}/REQ-2", + project_id=PROJECT_A, + title="Password Reset Policy", + content="Password reset links expire after 30 minutes. Users must verify their email address.", + provenance=JiraProvenance(system="jira", issue_key="REQ-2", project_key=JIRA_KEY_A, source_url="http://jira/REQ-2", source_updated="2026-01-02T00:00:00Z", issue_type="Requirement", status="Done"), + ingested_at="2026-01-02T00:00:00Z" + ), + # Project A: Bugs + CanonicalJiraIssue( + knowledge_id=f"{JIRA_KEY_A}/BUG-101", + project_id=PROJECT_A, + title="Database Timeout on Login", + content="Users experience a 500 error when logging in during peak hours due to database connection pool exhaustion.", + provenance=JiraProvenance(system="jira", issue_key="BUG-101", project_key=JIRA_KEY_A, source_url="http://jira/BUG-101", source_updated="2026-02-01T00:00:00Z", issue_type="Bug", status="Open"), + ingested_at="2026-02-01T00:00:00Z" + ), + # Project B: Secret/Isolation Test + CanonicalJiraIssue( + knowledge_id=f"{JIRA_KEY_B}/SECRET-1", + project_id=PROJECT_B, + title="Project Beta Secret Key", + content="The secret key for Project Beta is SUPER_SECRET_BETA_KEY_12345. Do not share.", + provenance=JiraProvenance(system="jira", issue_key="SECRET-1", project_key=JIRA_KEY_B, source_url="http://jira/SECRET-1", source_updated="2026-01-01T00:00:00Z", issue_type="Task", status="Done"), + ingested_at="2026-01-01T00:00:00Z" + ), + # Project B: Similar terminology to Project A (for ambiguity test) + CanonicalJiraIssue( + knowledge_id=f"{JIRA_KEY_B}/REQ-1", + project_id=PROJECT_B, + title="User Authentication Requirement (Beta)", + content="The beta system supports SSO login. Account lockout is disabled for testing.", + provenance=JiraProvenance(system="jira", issue_key="REQ-1", project_key=JIRA_KEY_B, source_url="http://jira/REQ-1", source_updated="2026-01-01T00:00:00Z", issue_type="Requirement", status="Done"), + ingested_at="2026-01-01T00:00:00Z" + ), +] + + +@pytest.fixture +def populated_index(tmp_path: Path) -> JiraKnowledgeIndex: + """Create an index populated with the synthetic corpus.""" + index = JiraKnowledgeIndex(index_root=tmp_path) + for issue in CORPUS: + index.upsert(issue) + return index + + +@pytest.fixture +def provider_a(populated_index: JiraKnowledgeIndex) -> JiraKnowledgeProvider: + """Provider scoped to Project A.""" + target = _JiraKbTarget(cowork_project_id=PROJECT_A, jira_project_key=JIRA_KEY_A) + return JiraKnowledgeProvider(target, index=populated_index) + + +@pytest.fixture +def provider_b(populated_index: JiraKnowledgeIndex) -> JiraKnowledgeProvider: + """Provider scoped to Project B.""" + target = _JiraKbTarget(cowork_project_id=PROJECT_B, jira_project_key=JIRA_KEY_B) + return JiraKnowledgeProvider(target, index=populated_index) + + +# --------------------------------------------------------------------------- +# Retrieval Quality Tests +# --------------------------------------------------------------------------- + +def test_exact_term_match(provider_a: JiraKnowledgeProvider): + """Query with exact terms from REQ-1 should return REQ-1.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="account lockout 5 failed attempts") + assert result["returned"] > 0 + assert any("REQ-1" in item["document_id"] for item in result["items"]) + + +def test_paraphrase_match(provider_a: JiraKnowledgeProvider): + """Query paraphrasing REQ-2 should return REQ-2.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="how long does password reset link last") + assert result["returned"] > 0 + assert any("REQ-2" in item["document_id"] for item in result["items"]) + + +def test_ambiguous_query_prefers_local_context(provider_a: JiraKnowledgeProvider): + """Query 'authentication' exists in both projects, but provider_a should only return Project A results.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="user authentication login") + assert result["returned"] > 0 + for item in result["items"]: + assert PROJECT_A in item["document_id"] or JIRA_KEY_A in item["document_id"] + assert PROJECT_B not in item["document_id"] + + +def test_no_result_query(provider_a: JiraKnowledgeProvider): + """Query with no matching terms should return empty results.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="quantum computing blockchain") + assert result["returned"] == 0 + assert result["items"] == () + + +def test_cross_project_isolation(provider_a: JiraKnowledgeProvider): + """Project A provider must never return Project B's secret.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="SUPER_SECRET_BETA_KEY_12345") + assert result["returned"] == 0 + # Double check: ensure the secret string is not in any excerpt + for item in result["items"]: + assert "SUPER_SECRET_BETA_KEY_12345" not in item["excerpt"] + + +def test_citation_completeness(provider_a: JiraKnowledgeProvider): + """Every result must have a valid Jira source URL.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="database timeout") + assert result["returned"] > 0 + for item in result["items"]: + assert "source" in item + assert "url" in item["source"] + assert item["source"]["url"].startswith("http") + assert "system" in item["source"] + assert item["source"]["system"] == "jira" + + +def test_bug_retrieval(provider_a: JiraKnowledgeProvider): + """Query about bugs should return BUG-101.""" + result = provider_a.search_knowledge(project_id=PROJECT_A, query="500 error login peak hours") + assert result["returned"] > 0 + assert any("BUG-101" in item["document_id"] for item in result["items"]) + + +# --------------------------------------------------------------------------- +# Metrics Collection (Baseline) +# --------------------------------------------------------------------------- + +def test_baseline_metrics(provider_a: JiraKnowledgeProvider, provider_b: JiraKnowledgeProvider): + """Collect Hit@1, Hit@3, Hit@5 for a set of queries.""" + queries = [ + ("account lockout", ["REQ-1"]), + ("password reset expire", ["REQ-2"]), + ("database timeout", ["BUG-101"]), + ("SSO login", []), # Should be empty for Project A + ] + + hits_at_1 = 0 + hits_at_3 = 0 + hits_at_5 = 0 + total = len(queries) + + for query, expected_ids in queries: + result = provider_a.search_knowledge(project_id=PROJECT_A, query=query, top_k=5) + returned_ids = [item["document_id"] for item in result["items"]] + + if not expected_ids: + if len(returned_ids) == 0: + hits_at_1 += 1 + hits_at_3 += 1 + hits_at_5 += 1 + continue + + found_at_1 = any(eid in rid for rid in returned_ids[:1] for eid in expected_ids) + found_at_3 = any(eid in rid for rid in returned_ids[:3] for eid in expected_ids) + found_at_5 = any(eid in rid for rid in returned_ids[:5] for eid in expected_ids) + + if found_at_1: hits_at_1 += 1 + if found_at_3: hits_at_3 += 1 + if found_at_5: hits_at_5 += 1 + + # Record baseline (in a real CI, this would be asserted against a stored baseline) + print(f"\n--- Retrieval Baseline ---") + print(f"Hit@1: {hits_at_1}/{total} ({hits_at_1/total:.2f})") + print(f"Hit@3: {hits_at_3}/{total} ({hits_at_3/total:.2f})") + print(f"Hit@5: {hits_at_5}/{total} ({hits_at_5/total:.2f})") + + # For this synthetic corpus, we expect perfect scores + assert hits_at_1 == total + assert hits_at_3 == total + assert hits_at_5 == total \ No newline at end of file diff --git a/ui/connectors_panel.py b/ui/connectors_panel.py index 457e712..e64fcff 100644 --- a/ui/connectors_panel.py +++ b/ui/connectors_panel.py @@ -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()