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:
@@ -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__}",
|
||||
|
||||
@@ -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/<project_id>/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/<project_id>/issues/`: Indexed canonical issues (JSON).
|
||||
* `~/.cowork_local/jira_kb/<project_id>/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.
|
||||
@@ -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
|
||||
+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