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()
|
manifest.mark_attempt()
|
||||||
save_manifest(self._index._root, manifest)
|
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.
|
# Fall back to full sync when no cursor exists.
|
||||||
if incremental and not manifest.sync_cursor:
|
if incremental and not manifest.sync_cursor:
|
||||||
incremental = False
|
incremental = False
|
||||||
@@ -127,6 +142,22 @@ class JiraSyncService:
|
|||||||
total_indexed=total_indexed,
|
total_indexed=total_indexed,
|
||||||
)
|
)
|
||||||
save_manifest(self._index._root, manifest)
|
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(
|
return SyncResult(
|
||||||
processed=processed,
|
processed=processed,
|
||||||
failed=failed,
|
failed=failed,
|
||||||
@@ -138,11 +169,43 @@ class JiraSyncService:
|
|||||||
duration = time.monotonic() - start
|
duration = time.monotonic() - start
|
||||||
manifest.mark_failure(category=exc.code, failed=0)
|
manifest.mark_failure(category=exc.code, failed=0)
|
||||||
save_manifest(self._index._root, manifest)
|
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
|
raise
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
duration = time.monotonic() - start
|
duration = time.monotonic() - start
|
||||||
manifest.mark_failure(category="UNEXPECTED", failed=0)
|
manifest.mark_failure(category="UNEXPECTED", failed=0)
|
||||||
save_manifest(self._index._root, manifest)
|
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(
|
raise ProviderError(
|
||||||
"SYNC_FAILED",
|
"SYNC_FAILED",
|
||||||
f"Jira sync failed: {type(exc).__name__}",
|
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.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
|
QCheckBox, QDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QLabel,
|
||||||
QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
QLineEdit, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
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):
|
class JiraConnectDialog(QDialog):
|
||||||
"""Minimal Jira connect — paste any Jira link (it fills the base URL) + email
|
"""Jira connection and Project Knowledge configuration.
|
||||||
+ API token. Once connected, pasting a Jira link into Cowork / Co4E chat is
|
|
||||||
read and processed automatically (no per-request setup)."""
|
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):
|
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)
|
super().__init__(parent)
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.setWindowTitle(tr("connectors.jira_group"))
|
self.setWindowTitle(tr("connectors.jira_group"))
|
||||||
self.setMinimumWidth(460)
|
self.setMinimumWidth(520)
|
||||||
jira = ctx.config.data.get("jira", {})
|
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 = QLabel(tr("connectors.jira_hint"))
|
||||||
hint.setObjectName("hint"); hint.setWordWrap(True); hint.setOpenExternalLinks(True)
|
hint.setObjectName("hint"); hint.setWordWrap(True); hint.setOpenExternalLinks(True)
|
||||||
form.addRow(hint)
|
conn_form.addRow(hint)
|
||||||
|
|
||||||
self.paste = QLineEdit()
|
self.paste = QLineEdit()
|
||||||
self.paste.setPlaceholderText(tr("connectors.jira_paste_placeholder"))
|
self.paste.setPlaceholderText(tr("connectors.jira_paste_placeholder"))
|
||||||
self.paste.textChanged.connect(self._on_paste)
|
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 = QLineEdit(jira.get("base_url", ""))
|
||||||
self.url.setPlaceholderText("https://your-domain.atlassian.net")
|
self.url.setPlaceholderText("https://your-domain.atlassian.net")
|
||||||
self.email = QLineEdit(jira.get("email", ""))
|
self.email = QLineEdit(jira.get("email", ""))
|
||||||
self.token = QLineEdit(jira.get("api_token", ""))
|
self.token = QLineEdit(jira.get("api_token", ""))
|
||||||
self.token.setEchoMode(QLineEdit.Password)
|
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 = QPushButton(tr("connectors.jira_test"))
|
||||||
self.test_btn.clicked.connect(self._test)
|
self.test_btn.clicked.connect(self._test)
|
||||||
self.save_btn = QPushButton(tr("connectors.jira_save"))
|
conn_row.addWidget(self.test_btn)
|
||||||
self.save_btn.setObjectName("primary"); self.save_btn.setIcon(icon("save"))
|
conn_row.addStretch(1)
|
||||||
self.save_btn.clicked.connect(self._save_close)
|
conn_group.setLayout(conn_form)
|
||||||
row.addWidget(self.test_btn); row.addStretch(1); row.addWidget(self.save_btn)
|
main_layout.addWidget(conn_group)
|
||||||
rw = QWidget(); rw.setLayout(row)
|
|
||||||
form.addRow(rw)
|
|
||||||
|
|
||||||
def _on_paste(self, text: str) -> None:
|
# === Project Knowledge Section ===
|
||||||
"""Dán một link Jira bất kỳ thì tự rút ra base URL — người dùng không phải
|
kb_group = QGroupBox("Project Knowledge")
|
||||||
biết đâu là phần gốc của địa chỉ.
|
kb_layout = QVBoxLayout(kb_group)
|
||||||
"""
|
|
||||||
from ..core import jira_tool
|
self.kb_enabled = QCheckBox("Enable Jira Project Knowledge")
|
||||||
base = jira_tool.base_url_from_link(text)
|
self.kb_enabled.setChecked(jira_kb.get("enabled", False))
|
||||||
if base:
|
kb_layout.addWidget(self.kb_enabled)
|
||||||
self.url.setText(base)
|
|
||||||
|
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:
|
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 = self.ctx.config.data.setdefault("jira", {})
|
||||||
j.update({"base_url": self.url.text().strip(), "email": self.email.text().strip(),
|
j.update({"base_url": self.url.text().strip(), "email": self.email.text().strip(),
|
||||||
"api_token": self.token.text().strip()})
|
"api_token": self.token.text().strip()})
|
||||||
j.setdefault("enabled", True)
|
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()
|
self.ctx.save()
|
||||||
|
|
||||||
def _save_close(self) -> None:
|
def _save_close(self) -> None:
|
||||||
@@ -96,9 +242,9 @@ class JiraConnectDialog(QDialog):
|
|||||||
self._save()
|
self._save()
|
||||||
cfg = self.ctx.config.data.get("jira", {})
|
cfg = self.ctx.config.data.get("jira", {})
|
||||||
if not jira_tool.configured(cfg):
|
if not jira_tool.configured(cfg):
|
||||||
self.status.setText(tr("connectors.jira_need_fields"))
|
self.conn_status.setText(tr("connectors.jira_need_fields"))
|
||||||
return
|
return
|
||||||
self.status.setText(tr("connectors.jira_testing"))
|
self.conn_status.setText(tr("connectors.jira_testing"))
|
||||||
self.test_btn.setEnabled(False)
|
self.test_btn.setEnabled(False)
|
||||||
|
|
||||||
def job(_w):
|
def job(_w):
|
||||||
@@ -112,13 +258,13 @@ class JiraConnectDialog(QDialog):
|
|||||||
self.test_btn.setEnabled(True)
|
self.test_btn.setEnabled(True)
|
||||||
out = r.get("out", "")
|
out = r.get("out", "")
|
||||||
ok = not out.lower().startswith(("jira is not configured", "jira search failed"))
|
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]))
|
else tr("connectors.jira_fail", err=out[:200]))
|
||||||
|
|
||||||
w = AgentWorker(job)
|
w = AgentWorker(job)
|
||||||
w.finished_ok.connect(done)
|
w.finished_ok.connect(done)
|
||||||
w.failed.connect(lambda e: (self.test_btn.setEnabled(True),
|
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
|
self._jira_worker = w
|
||||||
w.start()
|
w.start()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user