import sqlite3 from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import httpx import pytest from investibook.core.jira_comment import ( MAX_COMMENT_CHARS, _diagnosis_candidate_context, _jira_comment_confirmation, _normalize_jira_comment_headings, _strip_jira_comment_confirmation, _workspace_agent_llm, create_draft, publish_pending_draft, tcoc_issue_key, ) from investibook.db import queries as dbq def test_tcoc_issue_key_requires_exactly_one_tcoc_source(): assert tcoc_issue_key([{"type": "jira_issue", "issueId": "TCOC-42"}]) == "TCOC-42" assert tcoc_issue_key([{"type": "jira_issue", "issueId": "O11Y-42"}]) is None assert tcoc_issue_key([ {"type": "jira_issue", "issueId": "TCOC-42"}, {"type": "jira_issue", "issueId": "TCOC-43"}, ]) is None @pytest.mark.asyncio async def test_publish_pending_draft_uses_editor_text_without_agent(monkeypatch): draft = {"id": "draft-1", "issue_key": "TCOC-42", "body": "draft", "language": "ja"} monkeypatch.setattr( "investibook.core.jira_comment._db_get_pending_jira_comment_draft", lambda workspace_id, draft_id: draft if (workspace_id, draft_id) == ("ws-1", "draft-1") else None, ) published: dict[str, str] = {} async def _publish( workspace_id: str, issue_key: str, body: str, user_email: str, *, draft_id: str, language: str | None = None, ) -> dict: published.update( workspace_id=workspace_id, issue_key=issue_key, body=body, user_email=user_email, draft_id=draft_id, language=language, ) return {"jira_comment_id": "comment-1"} monkeypatch.setattr("investibook.core.jira_comment.publish", _publish) result = await publish_pending_draft("ws-1", "draft-1", "edited by user", "user@example.com") assert published == { "workspace_id": "ws-1", "issue_key": "TCOC-42", "body": "edited by user", "user_email": "user@example.com", "draft_id": "draft-1", "language": "ja", } assert result["jira_comment_id"] == "comment-1" @pytest.mark.asyncio async def test_publish_appends_confirmation_and_marks_draft_published(monkeypatch): from investibook.core import jira_comment response = MagicMock() response.json.return_value = {"id": "comment-1"} response.raise_for_status.return_value = None client = MagicMock() client.post = AsyncMock(return_value=response) http_client = MagicMock() http_client.__aenter__ = AsyncMock(return_value=client) http_client.__aexit__ = AsyncMock(return_value=None) marked: list[tuple[str, str, str]] = [] monkeypatch.setattr(jira_comment, "load_jira_http_config", lambda: ("https://jira.example", "bot", "token")) monkeypatch.setattr(jira_comment.httpx, "AsyncClient", lambda **_kwargs: http_client) monkeypatch.setattr( jira_comment, "_db_mark_jira_comment_draft_published", lambda *args: marked.append(args) or True, ) result = await jira_comment.publish( "ws-1", "TCOC-42", "由 Investibook 生成,经 user@example.com 确认。\n\n" "**Diagnosis Conclusion**\n用户编辑的正文\n\n**下一步动作**\n- 执行修复。", "user@example.com", draft_id="draft-1", ) assert result == {"jira_comment_id": "comment-1"} assert marked == [("ws-1", "draft-1", "user@example.com")] assert client.post.await_args.kwargs["json"] == { "body": ( "由 Investibook 生成,经 user@example.com 确认。\n\n" "*诊断结论*\n用户编辑的正文\n\n" "*下一步动作*\n- 执行修复。" ) } @pytest.mark.asyncio async def test_publish_converts_jira_http_error_to_explicit_failure(monkeypatch): from investibook.core import jira_comment response = MagicMock() response.raise_for_status.side_effect = httpx.HTTPStatusError( "Client error '403 Forbidden'", request=httpx.Request("POST", "https://jira.example/comment"), response=httpx.Response(403), ) client = MagicMock() client.post = AsyncMock(return_value=response) http_client = MagicMock() http_client.__aenter__ = AsyncMock(return_value=client) http_client.__aexit__ = AsyncMock(return_value=None) monkeypatch.setattr(jira_comment, "load_jira_http_config", lambda: ("https://jira.example", "bot", "token")) monkeypatch.setattr(jira_comment.httpx, "AsyncClient", lambda **_kwargs: http_client) with pytest.raises(RuntimeError, match="Jira request failed:.*403 Forbidden"): await jira_comment.publish("ws-1", "TCOC-42", "正文", "user@example.com", draft_id="draft-1") @pytest.mark.asyncio async def test_create_draft_uses_workspace_agent(monkeypatch): recorded: dict[str, str] = {} monkeypatch.setattr("investibook.core.jira_comment._db_has_pending_jira_comment_draft", lambda _: False) monkeypatch.setattr("investibook.core.jira_comment._diagnosis_candidate_context", lambda _: "assistant: supported conclusion") async def _agent(workspace_id: str, user_email: str, prompt: str) -> str: recorded.update(workspace_id=workspace_id, user_email=user_email, prompt=prompt) return ( "LANGUAGE: zh\n" "由 Investibook 生成,经 user@example.com 确认。\n\n" "**Problem Description**\n客户查询延迟升高。\n\n" "**Diagnosis Conclusion**\n已确认。\n\n" "**Key Evidence**\n- 证据充分。\n\n" "**下一步动作**\n- 执行修复。" ) monkeypatch.setattr("investibook.core.jira_comment._workspace_agent_llm", _agent) monkeypatch.setattr( "investibook.core.jira_comment._db_create_jira_comment_draft", lambda workspace_id, issue_key, body, language: { "id": "draft-1", "workspace_id": workspace_id, "issue_key": issue_key, "body": body, "language": language, }, ) draft = await create_draft("ws-1", "TCOC-42", "user@example.com") assert draft["issue_key"] == "TCOC-42" assert draft["language"] == "zh" assert draft["body"] == ( "*问题描述*\n客户查询延迟升高。\n\n" "*诊断结论*\n已确认。\n\n" "*关键证据*\n- 证据充分。\n\n" "*下一步动作*\n- 执行修复。" ) assert recorded["workspace_id"] == "ws-1" assert recorded["user_email"] == "user@example.com" assert "Do not run tools" in recorded["prompt"] assert "at most 5 evidence bullets" in recorded["prompt"] assert "Jira wiki bold syntax" in recorded["prompt"] assert "Chinese:" in recorded["prompt"] assert "English:" in recorded["prompt"] assert "Japanese:" in recorded["prompt"] assert "LANGUAGE: zh" in recorded["prompt"] assert "待确认项与限制" not in recorded["prompt"] assert "Always include the problem description" in recorded["prompt"] assert "one or two concise sentences" in recorded["prompt"] assert "exactly this order" in recorded["prompt"] assert "If the root cause remains uncertain" in recorded["prompt"] assert "Do not include any Investibook-generated" in recorded["prompt"] @pytest.mark.asyncio async def test_create_draft_requires_existing_draft_to_be_resolved(monkeypatch): agent = AsyncMock() monkeypatch.setattr("investibook.core.jira_comment._db_has_pending_jira_comment_draft", lambda _: True) monkeypatch.setattr("investibook.core.jira_comment._workspace_agent_llm", agent) with pytest.raises(ValueError, match="Publish or cancel"): await create_draft("ws-1", "TCOC-42", "user@example.com") agent.assert_not_awaited() @pytest.mark.parametrize( ("body", "language", "expected"), [ ( "**Problem Description**\n查询延迟升高。\n\n**诊断结论**\n根因已确认。\n\n**Key Evidence**\n- 日志已验证。", "zh", "*问题描述*\n查询延迟升高。\n\n*诊断结论*\n根因已确认。\n\n*关键证据*\n- 日志已验证。", ), ( "**诊断结论**\nThe root cause is confirmed.\n\n**关键证据**\n- Logs confirm it.", "en", "*Diagnosis Conclusion*\nThe root cause is confirmed.\n\n*Key Evidence*\n- Logs confirm it.", ), ( "**Problem Description**\n遅延が増加しました。\n\n**Diagnosis Conclusion**\n原因を確認しました。\n\n**Further Analysis Needed**\n- 追加ログを確認します。", "ja", "*問題の概要*\n遅延が増加しました。\n\n*診断結論*\n原因を確認しました。\n\n*必要な追加情報*\n- 追加ログを確認します。", ), ], ) def test_normalize_jira_comment_headings_uses_selected_language( body: str, language: str, expected: str ): assert _normalize_jira_comment_headings(body, language) == expected @pytest.mark.parametrize( ("language", "expected"), [ ("zh", "由 Investibook 生成,经 user@example.com 确认。"), ("en", "Generated by Investibook and confirmed by user@example.com."), ("ja", "Investibook により生成され、user@example.com により確認されました。"), ], ) def test_jira_comment_confirmation_uses_selected_language(language: str, expected: str): assert _jira_comment_confirmation("user@example.com", language) == expected @pytest.mark.parametrize( "confirmation", [ "由 Investibook 生成,经 user@example.com 确认。", "Generated by Investibook and confirmed by user@example.com.", "Investibook により生成され、user@example.com により確認されました。", ], ) def test_strip_jira_comment_confirmation_in_all_supported_languages(confirmation: str): assert _strip_jira_comment_confirmation(f"{confirmation}\n\n*Diagnosis Conclusion*\nConfirmed.") == ( "*Diagnosis Conclusion*\nConfirmed." ) @pytest.mark.asyncio async def test_create_draft_rejects_language_marker_without_conclusion(monkeypatch): monkeypatch.setattr("investibook.core.jira_comment._db_has_pending_jira_comment_draft", lambda _: False) monkeypatch.setattr( "investibook.core.jira_comment._diagnosis_candidate_context", lambda _: "user: 请分析问题", ) monkeypatch.setattr( "investibook.core.jira_comment._workspace_agent_llm", AsyncMock(return_value="LANGUAGE: zh\nNO_CONCLUSION"), ) with pytest.raises(ValueError, match="No publishable diagnosis conclusion"): await create_draft("ws-1", "TCOC-42", "user@example.com") def test_db_init_migrates_jira_comment_drafts_to_multiple_records(tmp_path, monkeypatch): db_path = tmp_path / "investibook.db" conn = sqlite3.connect(db_path) conn.execute( """CREATE TABLE workspace_jira_comment_drafts ( workspace_id TEXT PRIMARY KEY, issue_key TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""" ) conn.execute( "INSERT INTO workspace_jira_comment_drafts VALUES (?, ?, ?, ?, ?, ?, ?)", ("ws-1", "TCOC-42", "旧草稿", "published", "author@example.com", "old", "old"), ) conn.commit() conn.close() monkeypatch.setattr(dbq, "DB_PATH", db_path) dbq._db_init() first = dbq._db_create_jira_comment_draft("ws-1", "TCOC-42", "正文一", "zh") with pytest.raises(ValueError, match="Publish or cancel"): dbq._db_create_jira_comment_draft("ws-1", "TCOC-42", "正文二", "ja") assert dbq._db_mark_jira_comment_draft_published("ws-1", first["id"], "publisher@example.com") assert dbq._db_get_pending_jira_comment_draft("ws-1", first["id"]) is None second = dbq._db_create_jira_comment_draft("ws-1", "TCOC-42", "正文二", "ja") assert first["id"] != second["id"] assert dbq._db_get_pending_jira_comment_draft("ws-1", second["id"])["language"] == "ja" conn = sqlite3.connect(db_path) columns = {row[1] for row in conn.execute("PRAGMA table_info(workspace_jira_comment_drafts)")} published_by = conn.execute( "SELECT published_by FROM workspace_jira_comment_drafts WHERE id = ?", (first["id"],) ).fetchone() legacy_published_by = conn.execute( "SELECT published_by FROM workspace_jira_comment_drafts WHERE body = '旧草稿'" ).fetchone() conn.close() assert "id" in columns assert "created_by" not in columns assert published_by == ("publisher@example.com",) assert legacy_published_by == ("author@example.com",) def test_diagnosis_candidate_context_excludes_commands_and_system_messages(monkeypatch): monkeypatch.setattr( "investibook.core.jira_comment._db_get_messages_v2", lambda *_args, **_kwargs: [ {"role": "user", "content": "开始诊断"}, {"role": "assistant", "content": "诊断结论:已确认根因"}, {"role": "user", "content": "/force-continue"}, {"role": "system", "content": "成本控制已跳过"}, {"role": "user", "content": "/comment"}, {"role": "assistant", "content": "补充:该结论置信度高"}, ], ) context = _diagnosis_candidate_context("ws-1") assert "已确认根因" in context assert "补充:该结论置信度高" in context assert "/force-continue" not in context assert "成本控制已跳过" not in context assert "/comment" not in context @pytest.mark.asyncio async def test_workspace_agent_llm_uses_acp_session(monkeypatch): recorded: dict[str, object] = {} async def _ask_and_wait(workspace_id: str, prompt: str, **kwargs): recorded.update(workspace_id=workspace_id, prompt=prompt, kwargs=kwargs) return SimpleNamespace(finish_reason="completed", text="draft", error=None) monkeypatch.setattr("investibook.core.jira_comment.ask_and_wait", _ask_and_wait) body = await _workspace_agent_llm("ws-1", "user@example.com", "format this") assert body == "draft" assert recorded["workspace_id"] == "ws-1" assert recorded["prompt"] == "format this" assert recorded["kwargs"] == { "user_email": "user@example.com", "timeout_s": 90, "idle_timeout_s": 45, } def test_max_comment_length_is_product_limit(): assert MAX_COMMENT_CHARS == 3000