raise-cli 2.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raise_cli/__init__.py +38 -0
- raise_cli/__main__.py +30 -0
- raise_cli/adapters/__init__.py +91 -0
- raise_cli/adapters/declarative/__init__.py +26 -0
- raise_cli/adapters/declarative/adapter.py +267 -0
- raise_cli/adapters/declarative/discovery.py +94 -0
- raise_cli/adapters/declarative/expressions.py +150 -0
- raise_cli/adapters/declarative/reference/__init__.py +1 -0
- raise_cli/adapters/declarative/reference/github.yaml +143 -0
- raise_cli/adapters/declarative/schema.py +98 -0
- raise_cli/adapters/filesystem.py +299 -0
- raise_cli/adapters/mcp_bridge.py +10 -0
- raise_cli/adapters/mcp_confluence.py +246 -0
- raise_cli/adapters/mcp_jira.py +405 -0
- raise_cli/adapters/models.py +205 -0
- raise_cli/adapters/protocols.py +180 -0
- raise_cli/adapters/registry.py +90 -0
- raise_cli/adapters/sync.py +149 -0
- raise_cli/agents/__init__.py +14 -0
- raise_cli/agents/antigravity.yaml +8 -0
- raise_cli/agents/claude.yaml +8 -0
- raise_cli/agents/copilot.yaml +8 -0
- raise_cli/agents/copilot_plugin.py +124 -0
- raise_cli/agents/cursor.yaml +7 -0
- raise_cli/agents/roo.yaml +8 -0
- raise_cli/agents/windsurf.yaml +8 -0
- raise_cli/artifacts/__init__.py +30 -0
- raise_cli/artifacts/models.py +43 -0
- raise_cli/artifacts/reader.py +55 -0
- raise_cli/artifacts/renderer.py +104 -0
- raise_cli/artifacts/story_design.py +69 -0
- raise_cli/artifacts/writer.py +45 -0
- raise_cli/backlog/__init__.py +1 -0
- raise_cli/backlog/sync.py +115 -0
- raise_cli/cli/__init__.py +3 -0
- raise_cli/cli/commands/__init__.py +3 -0
- raise_cli/cli/commands/_resolve.py +153 -0
- raise_cli/cli/commands/adapters.py +362 -0
- raise_cli/cli/commands/artifact.py +137 -0
- raise_cli/cli/commands/backlog.py +333 -0
- raise_cli/cli/commands/base.py +31 -0
- raise_cli/cli/commands/discover.py +551 -0
- raise_cli/cli/commands/docs.py +130 -0
- raise_cli/cli/commands/doctor.py +177 -0
- raise_cli/cli/commands/gate.py +223 -0
- raise_cli/cli/commands/graph.py +1086 -0
- raise_cli/cli/commands/info.py +81 -0
- raise_cli/cli/commands/init.py +746 -0
- raise_cli/cli/commands/journal.py +167 -0
- raise_cli/cli/commands/mcp.py +524 -0
- raise_cli/cli/commands/memory.py +467 -0
- raise_cli/cli/commands/pattern.py +348 -0
- raise_cli/cli/commands/profile.py +59 -0
- raise_cli/cli/commands/publish.py +80 -0
- raise_cli/cli/commands/release.py +338 -0
- raise_cli/cli/commands/session.py +528 -0
- raise_cli/cli/commands/signal.py +410 -0
- raise_cli/cli/commands/skill.py +350 -0
- raise_cli/cli/commands/skill_set.py +145 -0
- raise_cli/cli/error_handler.py +158 -0
- raise_cli/cli/main.py +163 -0
- raise_cli/compat.py +66 -0
- raise_cli/config/__init__.py +41 -0
- raise_cli/config/agent_plugin.py +105 -0
- raise_cli/config/agent_registry.py +233 -0
- raise_cli/config/agents.py +120 -0
- raise_cli/config/ide.py +32 -0
- raise_cli/config/paths.py +379 -0
- raise_cli/config/settings.py +180 -0
- raise_cli/context/__init__.py +42 -0
- raise_cli/context/analyzers/__init__.py +16 -0
- raise_cli/context/analyzers/models.py +36 -0
- raise_cli/context/analyzers/protocol.py +43 -0
- raise_cli/context/analyzers/python.py +292 -0
- raise_cli/context/builder.py +1569 -0
- raise_cli/context/diff.py +213 -0
- raise_cli/context/extractors/__init__.py +13 -0
- raise_cli/context/extractors/skills.py +121 -0
- raise_cli/core/__init__.py +37 -0
- raise_cli/core/files.py +66 -0
- raise_cli/core/text.py +174 -0
- raise_cli/core/tools.py +441 -0
- raise_cli/discovery/__init__.py +50 -0
- raise_cli/discovery/analyzer.py +691 -0
- raise_cli/discovery/drift.py +355 -0
- raise_cli/discovery/scanner.py +1687 -0
- raise_cli/doctor/__init__.py +4 -0
- raise_cli/doctor/checks/__init__.py +1 -0
- raise_cli/doctor/checks/environment.py +110 -0
- raise_cli/doctor/checks/project.py +238 -0
- raise_cli/doctor/fix.py +80 -0
- raise_cli/doctor/models.py +56 -0
- raise_cli/doctor/protocol.py +43 -0
- raise_cli/doctor/registry.py +100 -0
- raise_cli/doctor/report.py +141 -0
- raise_cli/doctor/runner.py +95 -0
- raise_cli/engines/__init__.py +3 -0
- raise_cli/exceptions.py +215 -0
- raise_cli/gates/__init__.py +19 -0
- raise_cli/gates/builtin/__init__.py +1 -0
- raise_cli/gates/builtin/coverage.py +52 -0
- raise_cli/gates/builtin/lint.py +48 -0
- raise_cli/gates/builtin/tests.py +48 -0
- raise_cli/gates/builtin/types.py +48 -0
- raise_cli/gates/models.py +40 -0
- raise_cli/gates/protocol.py +41 -0
- raise_cli/gates/registry.py +141 -0
- raise_cli/governance/__init__.py +11 -0
- raise_cli/governance/extractor.py +412 -0
- raise_cli/governance/models.py +134 -0
- raise_cli/governance/parsers/__init__.py +35 -0
- raise_cli/governance/parsers/_convert.py +38 -0
- raise_cli/governance/parsers/adr.py +274 -0
- raise_cli/governance/parsers/backlog.py +356 -0
- raise_cli/governance/parsers/constitution.py +119 -0
- raise_cli/governance/parsers/epic.py +323 -0
- raise_cli/governance/parsers/glossary.py +316 -0
- raise_cli/governance/parsers/guardrails.py +345 -0
- raise_cli/governance/parsers/prd.py +112 -0
- raise_cli/governance/parsers/roadmap.py +118 -0
- raise_cli/governance/parsers/vision.py +116 -0
- raise_cli/graph/__init__.py +1 -0
- raise_cli/graph/backends/__init__.py +57 -0
- raise_cli/graph/backends/api.py +137 -0
- raise_cli/graph/backends/dual.py +139 -0
- raise_cli/graph/backends/pending.py +84 -0
- raise_cli/handlers/__init__.py +3 -0
- raise_cli/hooks/__init__.py +54 -0
- raise_cli/hooks/builtin/__init__.py +1 -0
- raise_cli/hooks/builtin/backlog.py +216 -0
- raise_cli/hooks/builtin/gate_bridge.py +83 -0
- raise_cli/hooks/builtin/jira_sync.py +127 -0
- raise_cli/hooks/builtin/memory.py +117 -0
- raise_cli/hooks/builtin/telemetry.py +72 -0
- raise_cli/hooks/emitter.py +184 -0
- raise_cli/hooks/events.py +262 -0
- raise_cli/hooks/protocol.py +38 -0
- raise_cli/hooks/registry.py +117 -0
- raise_cli/mcp/__init__.py +33 -0
- raise_cli/mcp/bridge.py +218 -0
- raise_cli/mcp/models.py +43 -0
- raise_cli/mcp/registry.py +77 -0
- raise_cli/mcp/schema.py +41 -0
- raise_cli/memory/__init__.py +58 -0
- raise_cli/memory/loader.py +247 -0
- raise_cli/memory/migration.py +241 -0
- raise_cli/memory/models.py +169 -0
- raise_cli/memory/writer.py +598 -0
- raise_cli/onboarding/__init__.py +103 -0
- raise_cli/onboarding/bootstrap.py +324 -0
- raise_cli/onboarding/claudemd.py +17 -0
- raise_cli/onboarding/conventions.py +742 -0
- raise_cli/onboarding/detection.py +374 -0
- raise_cli/onboarding/governance.py +443 -0
- raise_cli/onboarding/instructions.py +672 -0
- raise_cli/onboarding/manifest.py +201 -0
- raise_cli/onboarding/memory_md.py +399 -0
- raise_cli/onboarding/migration.py +207 -0
- raise_cli/onboarding/profile.py +624 -0
- raise_cli/onboarding/skill_conflict.py +100 -0
- raise_cli/onboarding/skill_manifest.py +176 -0
- raise_cli/onboarding/skills.py +437 -0
- raise_cli/onboarding/workflows.py +101 -0
- raise_cli/output/__init__.py +28 -0
- raise_cli/output/console.py +394 -0
- raise_cli/output/formatters/__init__.py +9 -0
- raise_cli/output/formatters/adapters.py +135 -0
- raise_cli/output/formatters/discover.py +439 -0
- raise_cli/output/formatters/skill.py +298 -0
- raise_cli/publish/__init__.py +3 -0
- raise_cli/publish/changelog.py +80 -0
- raise_cli/publish/check.py +179 -0
- raise_cli/publish/version.py +172 -0
- raise_cli/rai_base/__init__.py +22 -0
- raise_cli/rai_base/framework/__init__.py +7 -0
- raise_cli/rai_base/framework/methodology.yaml +233 -0
- raise_cli/rai_base/governance/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
- raise_cli/rai_base/governance/architecture/system-context.md +34 -0
- raise_cli/rai_base/governance/architecture/system-design.md +24 -0
- raise_cli/rai_base/governance/backlog.md +8 -0
- raise_cli/rai_base/governance/guardrails.md +17 -0
- raise_cli/rai_base/governance/prd.md +25 -0
- raise_cli/rai_base/governance/vision.md +16 -0
- raise_cli/rai_base/identity/__init__.py +8 -0
- raise_cli/rai_base/identity/core.md +119 -0
- raise_cli/rai_base/identity/perspective.md +119 -0
- raise_cli/rai_base/memory/__init__.py +7 -0
- raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
- raise_cli/schemas/__init__.py +3 -0
- raise_cli/schemas/journal.py +49 -0
- raise_cli/schemas/session_state.py +117 -0
- raise_cli/session/__init__.py +5 -0
- raise_cli/session/bundle.py +820 -0
- raise_cli/session/close.py +268 -0
- raise_cli/session/journal.py +119 -0
- raise_cli/session/resolver.py +126 -0
- raise_cli/session/state.py +187 -0
- raise_cli/skills/__init__.py +44 -0
- raise_cli/skills/locator.py +141 -0
- raise_cli/skills/name_checker.py +199 -0
- raise_cli/skills/parser.py +145 -0
- raise_cli/skills/scaffold.py +212 -0
- raise_cli/skills/schema.py +132 -0
- raise_cli/skills/skillsets.py +195 -0
- raise_cli/skills/validator.py +197 -0
- raise_cli/skills_base/__init__.py +80 -0
- raise_cli/skills_base/contract-template.md +60 -0
- raise_cli/skills_base/preamble.md +37 -0
- raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
- raise_cli/skills_base/rai-debug/SKILL.md +171 -0
- raise_cli/skills_base/rai-discover/SKILL.md +167 -0
- raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
- raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
- raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
- raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
- raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
- raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
- raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
- raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
- raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
- raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
- raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
- raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
- raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
- raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
- raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
- raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
- raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
- raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
- raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
- raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
- raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
- raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
- raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
- raise_cli/skills_base/rai-research/SKILL.md +143 -0
- raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
- raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
- raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
- raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
- raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
- raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
- raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
- raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
- raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
- raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
- raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
- raise_cli/telemetry/__init__.py +42 -0
- raise_cli/telemetry/schemas.py +285 -0
- raise_cli/telemetry/writer.py +217 -0
- raise_cli/tier/__init__.py +0 -0
- raise_cli/tier/context.py +134 -0
- raise_cli/viz/__init__.py +7 -0
- raise_cli/viz/generator.py +406 -0
- raise_cli-2.2.1.dist-info/METADATA +433 -0
- raise_cli-2.2.1.dist-info/RECORD +264 -0
- raise_cli-2.2.1.dist-info/WHEEL +4 -0
- raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
- raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
- raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Confluence adapter via MCP bridge (mcp-atlassian server).
|
|
2
|
+
|
|
3
|
+
Implements ``AsyncDocumentationTarget`` by mapping each protocol method
|
|
4
|
+
to the corresponding ``mcp-atlassian`` Confluence tool via ``McpBridge``.
|
|
5
|
+
|
|
6
|
+
Configuration: reads ``.raise/confluence.yaml`` for space_key.
|
|
7
|
+
Connection: lazy — no MCP server connection until first method call.
|
|
8
|
+
Page tracking: ``.raise/confluence-pages.yaml`` maps artifact types to page IDs.
|
|
9
|
+
|
|
10
|
+
Architecture: S301.5 design (D1-D7)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from raise_cli.adapters.models import (
|
|
23
|
+
AdapterHealth,
|
|
24
|
+
PageContent,
|
|
25
|
+
PageSummary,
|
|
26
|
+
PublishResult,
|
|
27
|
+
)
|
|
28
|
+
from raise_cli.mcp.bridge import McpBridge, McpBridgeError, McpToolResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class McpConfluenceAdapter:
|
|
32
|
+
"""Confluence adapter that delegates to mcp-atlassian via McpBridge.
|
|
33
|
+
|
|
34
|
+
Implements ``AsyncDocumentationTarget`` protocol (structural typing).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
project_root: Project root directory containing ``.raise/confluence.yaml``.
|
|
38
|
+
Defaults to current working directory.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
42
|
+
self._root = project_root or Path.cwd()
|
|
43
|
+
config = self._load_config(self._root)
|
|
44
|
+
|
|
45
|
+
space_key = config.get("space_key") or os.environ.get("CONFLUENCE_SPACE_KEY")
|
|
46
|
+
if not space_key:
|
|
47
|
+
msg = (
|
|
48
|
+
"Missing 'space_key' in .raise/confluence.yaml and "
|
|
49
|
+
"CONFLUENCE_SPACE_KEY env var not set."
|
|
50
|
+
)
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
|
|
53
|
+
self._space_key: str = space_key
|
|
54
|
+
self._pages_path = self._root / ".raise" / "confluence-pages.yaml"
|
|
55
|
+
self._bridge = self._create_bridge()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _create_bridge() -> McpBridge:
|
|
59
|
+
"""Create McpBridge with Confluence credentials from environment."""
|
|
60
|
+
server_args: list[str] = ["mcp-atlassian"]
|
|
61
|
+
confluence_url = os.environ.get("CONFLUENCE_URL")
|
|
62
|
+
if confluence_url:
|
|
63
|
+
server_args.extend(["--confluence-url", confluence_url])
|
|
64
|
+
confluence_user = os.environ.get("CONFLUENCE_USERNAME")
|
|
65
|
+
if confluence_user:
|
|
66
|
+
server_args.extend(["--confluence-username", confluence_user])
|
|
67
|
+
confluence_token = os.environ.get("CONFLUENCE_API_TOKEN")
|
|
68
|
+
if confluence_token:
|
|
69
|
+
server_args.extend(["--confluence-token", confluence_token])
|
|
70
|
+
return McpBridge(server_command="uvx", server_args=server_args)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _load_config(root: Path) -> dict[str, Any]:
|
|
74
|
+
"""Read and parse .raise/confluence.yaml."""
|
|
75
|
+
config_path = root / ".raise" / "confluence.yaml"
|
|
76
|
+
if not config_path.exists():
|
|
77
|
+
msg = f"Confluence config not found: {config_path}"
|
|
78
|
+
raise FileNotFoundError(msg)
|
|
79
|
+
with open(config_path, encoding="utf-8") as f:
|
|
80
|
+
data: dict[str, Any] = yaml.safe_load(f)
|
|
81
|
+
return data or {}
|
|
82
|
+
|
|
83
|
+
# ----- Page ID tracking (D3) -----
|
|
84
|
+
|
|
85
|
+
def _load_page_id(self, doc_type: str) -> str | None:
|
|
86
|
+
"""Load cached page ID for a doc type from confluence-pages.yaml."""
|
|
87
|
+
if not self._pages_path.exists():
|
|
88
|
+
return None
|
|
89
|
+
with open(self._pages_path, encoding="utf-8") as f:
|
|
90
|
+
pages: dict[str, str] = yaml.safe_load(f) or {}
|
|
91
|
+
return pages.get(doc_type)
|
|
92
|
+
|
|
93
|
+
def _save_page_id(self, doc_type: str, page_id: str) -> None:
|
|
94
|
+
"""Save page ID for a doc type to confluence-pages.yaml."""
|
|
95
|
+
pages: dict[str, str] = {}
|
|
96
|
+
if self._pages_path.exists():
|
|
97
|
+
with open(self._pages_path, encoding="utf-8") as f:
|
|
98
|
+
pages = yaml.safe_load(f) or {}
|
|
99
|
+
pages[doc_type] = page_id
|
|
100
|
+
with open(self._pages_path, "w", encoding="utf-8") as f:
|
|
101
|
+
yaml.dump(pages, f, default_flow_style=False)
|
|
102
|
+
|
|
103
|
+
def _remove_page_id(self, doc_type: str) -> None:
|
|
104
|
+
"""Remove a stale page ID entry from confluence-pages.yaml."""
|
|
105
|
+
if not self._pages_path.exists():
|
|
106
|
+
return
|
|
107
|
+
with open(self._pages_path, encoding="utf-8") as f:
|
|
108
|
+
pages: dict[str, str] = yaml.safe_load(f) or {}
|
|
109
|
+
pages.pop(doc_type, None)
|
|
110
|
+
with open(self._pages_path, "w", encoding="utf-8") as f:
|
|
111
|
+
yaml.dump(pages, f, default_flow_style=False)
|
|
112
|
+
|
|
113
|
+
# ----- DocumentationTarget methods -----
|
|
114
|
+
|
|
115
|
+
async def can_publish(self, doc_type: str, metadata: dict[str, Any]) -> bool:
|
|
116
|
+
"""Accept all doc types — no restrictions."""
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
async def publish(
|
|
120
|
+
self, doc_type: str, content: str, metadata: dict[str, Any]
|
|
121
|
+
) -> PublishResult:
|
|
122
|
+
"""Publish content to Confluence. Creates or updates based on tracking."""
|
|
123
|
+
title = metadata.get("title", doc_type)
|
|
124
|
+
page_id = self._load_page_id(doc_type)
|
|
125
|
+
|
|
126
|
+
if page_id:
|
|
127
|
+
# Try update existing page
|
|
128
|
+
try:
|
|
129
|
+
result = await self._bridge.call(
|
|
130
|
+
"confluence_update_page",
|
|
131
|
+
{"page_id": page_id, "title": title, "content": content},
|
|
132
|
+
)
|
|
133
|
+
return self._parse_publish_result(result)
|
|
134
|
+
except McpBridgeError as exc:
|
|
135
|
+
# Auto-heal only when page was deleted — not on auth/network errors
|
|
136
|
+
err = str(exc).lower()
|
|
137
|
+
if "not found" in err or "404" in err or "does not exist" in err:
|
|
138
|
+
self._remove_page_id(doc_type)
|
|
139
|
+
else:
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
# Create new page
|
|
143
|
+
result = await self._bridge.call(
|
|
144
|
+
"confluence_create_page",
|
|
145
|
+
{"space_key": self._space_key, "title": title, "content": content},
|
|
146
|
+
)
|
|
147
|
+
publish_result = self._parse_publish_result(result)
|
|
148
|
+
if publish_result.success:
|
|
149
|
+
# Extract page_id from response and save
|
|
150
|
+
page = result.data.get("page", {})
|
|
151
|
+
new_id = str(page.get("id", ""))
|
|
152
|
+
if new_id:
|
|
153
|
+
self._save_page_id(doc_type, new_id)
|
|
154
|
+
return publish_result
|
|
155
|
+
|
|
156
|
+
async def get_page(self, identifier: str) -> PageContent:
|
|
157
|
+
"""Retrieve a page by ID from Confluence."""
|
|
158
|
+
result = await self._bridge.call("confluence_get_page", {"page_id": identifier})
|
|
159
|
+
return self._parse_page_content(result)
|
|
160
|
+
|
|
161
|
+
async def search(self, query: str, limit: int = 10) -> list[PageSummary]:
|
|
162
|
+
"""Search Confluence pages."""
|
|
163
|
+
result = await self._bridge.call(
|
|
164
|
+
"confluence_search", {"query": query, "limit": limit}
|
|
165
|
+
)
|
|
166
|
+
return self._parse_search_results(result)
|
|
167
|
+
|
|
168
|
+
async def health(self) -> AdapterHealth:
|
|
169
|
+
"""Check Confluence connectivity via minimal search."""
|
|
170
|
+
start = time.monotonic()
|
|
171
|
+
try:
|
|
172
|
+
await self._bridge.call(
|
|
173
|
+
"confluence_search",
|
|
174
|
+
{"query": "type=page", "limit": 1},
|
|
175
|
+
)
|
|
176
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
177
|
+
return AdapterHealth(
|
|
178
|
+
name="confluence",
|
|
179
|
+
healthy=True,
|
|
180
|
+
message="OK",
|
|
181
|
+
latency_ms=elapsed_ms,
|
|
182
|
+
)
|
|
183
|
+
except (McpBridgeError, Exception) as exc:
|
|
184
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
185
|
+
return AdapterHealth(
|
|
186
|
+
name="confluence",
|
|
187
|
+
healthy=False,
|
|
188
|
+
message=str(exc),
|
|
189
|
+
latency_ms=elapsed_ms,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ----- Lifecycle -----
|
|
193
|
+
|
|
194
|
+
async def aclose(self) -> None:
|
|
195
|
+
"""Close the underlying MCP bridge (RAISE-324)."""
|
|
196
|
+
await self._bridge.aclose()
|
|
197
|
+
|
|
198
|
+
# ----- Response parsers (D5 — probed formats) -----
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _parse_publish_result(result: McpToolResult) -> PublishResult:
|
|
202
|
+
"""Parse create/update response into PublishResult.
|
|
203
|
+
|
|
204
|
+
Format: {"message": "...", "page": {"id": "...", "url": "...", ...}}
|
|
205
|
+
"""
|
|
206
|
+
if result.is_error:
|
|
207
|
+
return PublishResult(success=False, message=result.error_message)
|
|
208
|
+
page = result.data.get("page", {})
|
|
209
|
+
url = page.get("url", "")
|
|
210
|
+
return PublishResult(success=True, url=url)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _parse_page_content(result: McpToolResult) -> PageContent:
|
|
214
|
+
"""Parse get_page response into PageContent.
|
|
215
|
+
|
|
216
|
+
Format: {"metadata": {"id": "...", "title": "...", "content": {"value": "..."}, ...}}
|
|
217
|
+
"""
|
|
218
|
+
meta = result.data.get("metadata", {})
|
|
219
|
+
content_obj = meta.get("content", {})
|
|
220
|
+
space = meta.get("space", {})
|
|
221
|
+
return PageContent(
|
|
222
|
+
id=str(meta.get("id", "")),
|
|
223
|
+
title=meta.get("title", ""),
|
|
224
|
+
content=content_obj.get("value", ""),
|
|
225
|
+
url=meta.get("url", ""),
|
|
226
|
+
space_key=space.get("key", ""),
|
|
227
|
+
version=meta.get("version", 1),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _parse_search_results(result: McpToolResult) -> list[PageSummary]:
|
|
232
|
+
"""Parse search response into list[PageSummary].
|
|
233
|
+
|
|
234
|
+
Bridge wraps JSON array as {"items": [...]}.
|
|
235
|
+
"""
|
|
236
|
+
items = result.data.get("items", [])
|
|
237
|
+
return [
|
|
238
|
+
PageSummary(
|
|
239
|
+
id=str(item.get("id", "")),
|
|
240
|
+
title=item.get("title", ""),
|
|
241
|
+
url=item.get("url", ""),
|
|
242
|
+
space_key=item.get("space", {}).get("key", ""),
|
|
243
|
+
updated=item.get("updated", ""),
|
|
244
|
+
)
|
|
245
|
+
for item in items
|
|
246
|
+
]
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Jira adapter via MCP bridge (mcp-atlassian server).
|
|
2
|
+
|
|
3
|
+
Implements ``AsyncProjectManagementAdapter`` by mapping each protocol method
|
|
4
|
+
to the corresponding ``mcp-atlassian`` tool call via ``McpBridge``.
|
|
5
|
+
|
|
6
|
+
Configuration: reads ``.raise/jira.yaml`` for status_mapping and project config.
|
|
7
|
+
Connection: lazy — no MCP server connection until first method call.
|
|
8
|
+
|
|
9
|
+
Architecture: S301.3 design, AR-S3-2, AR-S3-5
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from raise_cli.adapters.models import (
|
|
23
|
+
AdapterHealth,
|
|
24
|
+
BatchResult,
|
|
25
|
+
Comment,
|
|
26
|
+
CommentRef,
|
|
27
|
+
FailureDetail,
|
|
28
|
+
IssueDetail,
|
|
29
|
+
IssueRef,
|
|
30
|
+
IssueSpec,
|
|
31
|
+
IssueSummary,
|
|
32
|
+
)
|
|
33
|
+
from raise_cli.mcp.bridge import McpBridge, McpBridgeError, McpToolResult
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class McpJiraAdapter:
|
|
37
|
+
"""Jira adapter that delegates to mcp-atlassian via McpBridge.
|
|
38
|
+
|
|
39
|
+
Implements ``AsyncProjectManagementAdapter`` protocol (structural typing).
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
project_root: Project root directory containing ``.raise/jira.yaml``.
|
|
43
|
+
Defaults to current working directory.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
47
|
+
root = project_root or Path.cwd()
|
|
48
|
+
config = self._load_jira_config(root)
|
|
49
|
+
|
|
50
|
+
workflow = config.get("workflow", {})
|
|
51
|
+
status_mapping = workflow.get("status_mapping")
|
|
52
|
+
if not status_mapping:
|
|
53
|
+
msg = (
|
|
54
|
+
"Missing 'status_mapping' in .raise/jira.yaml. "
|
|
55
|
+
"Add a status_mapping section with status names and transition IDs."
|
|
56
|
+
)
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
|
|
59
|
+
self._status_mapping: dict[str, int] = status_mapping
|
|
60
|
+
self._bridge = self._create_bridge()
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _create_bridge() -> McpBridge:
|
|
64
|
+
"""Create McpBridge with server args from environment variables.
|
|
65
|
+
|
|
66
|
+
Reads JIRA_URL, JIRA_USERNAME, and JIRA_API_TOKEN (or JIRA_TOKEN)
|
|
67
|
+
from environment and passes them as CLI args to mcp-atlassian.
|
|
68
|
+
"""
|
|
69
|
+
server_args: list[str] = ["mcp-atlassian"]
|
|
70
|
+
jira_url = os.environ.get("JIRA_URL")
|
|
71
|
+
if jira_url:
|
|
72
|
+
server_args.extend(["--jira-url", jira_url])
|
|
73
|
+
jira_username = os.environ.get("JIRA_USERNAME")
|
|
74
|
+
if jira_username:
|
|
75
|
+
server_args.extend(["--jira-username", jira_username])
|
|
76
|
+
jira_token = os.environ.get("JIRA_API_TOKEN") or os.environ.get("JIRA_TOKEN")
|
|
77
|
+
if jira_token:
|
|
78
|
+
server_args.extend(["--jira-token", jira_token])
|
|
79
|
+
return McpBridge(server_command="uvx", server_args=server_args)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _load_jira_config(root: Path) -> dict[str, Any]:
|
|
83
|
+
"""Read and parse .raise/jira.yaml."""
|
|
84
|
+
config_path = root / ".raise" / "jira.yaml"
|
|
85
|
+
if not config_path.exists():
|
|
86
|
+
msg = f"Jira config not found: {config_path}"
|
|
87
|
+
raise FileNotFoundError(msg)
|
|
88
|
+
with open(config_path) as f:
|
|
89
|
+
data: dict[str, Any] = yaml.safe_load(f)
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
def _resolve_transition_id(self, status: str) -> str:
|
|
93
|
+
"""Map status name to Jira transition ID.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
status: Status name (e.g. "done", "in-progress").
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Transition ID as string.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ValueError: If status is not in status_mapping.
|
|
103
|
+
"""
|
|
104
|
+
tid = self._status_mapping.get(status.lower())
|
|
105
|
+
if tid is None:
|
|
106
|
+
available = ", ".join(sorted(self._status_mapping.keys()))
|
|
107
|
+
msg = f"Unknown status '{status}'. Available: {available}"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
return str(tid)
|
|
110
|
+
|
|
111
|
+
# ----- CRUD -----
|
|
112
|
+
|
|
113
|
+
async def create_issue(self, project_key: str, issue: IssueSpec) -> IssueRef:
|
|
114
|
+
args: dict[str, Any] = {
|
|
115
|
+
"project_key": project_key,
|
|
116
|
+
"summary": issue.summary,
|
|
117
|
+
"issue_type": issue.issue_type,
|
|
118
|
+
}
|
|
119
|
+
if issue.description:
|
|
120
|
+
args["description"] = issue.description
|
|
121
|
+
if issue.labels:
|
|
122
|
+
args["additional_fields"] = json.dumps(
|
|
123
|
+
{**issue.metadata, "labels": issue.labels}
|
|
124
|
+
)
|
|
125
|
+
elif issue.metadata:
|
|
126
|
+
args["additional_fields"] = json.dumps(issue.metadata)
|
|
127
|
+
|
|
128
|
+
result = await self._bridge.call("jira_create_issue", args)
|
|
129
|
+
return self._parse_issue_ref(result)
|
|
130
|
+
|
|
131
|
+
async def get_issue(self, key: str) -> IssueDetail:
|
|
132
|
+
result = await self._bridge.call(
|
|
133
|
+
"jira_get_issue", {"issue_key": key, "fields": "*all"}
|
|
134
|
+
)
|
|
135
|
+
return self._parse_issue_detail(result)
|
|
136
|
+
|
|
137
|
+
async def update_issue(self, key: str, fields: dict[str, Any]) -> IssueRef:
|
|
138
|
+
result = await self._bridge.call(
|
|
139
|
+
"jira_update_issue",
|
|
140
|
+
{"issue_key": key, "fields": json.dumps(fields)},
|
|
141
|
+
)
|
|
142
|
+
return self._parse_issue_ref(result)
|
|
143
|
+
|
|
144
|
+
async def transition_issue(self, key: str, status: str) -> IssueRef:
|
|
145
|
+
tid = self._resolve_transition_id(status)
|
|
146
|
+
result = await self._bridge.call(
|
|
147
|
+
"jira_transition_issue",
|
|
148
|
+
{"issue_key": key, "transition_id": tid},
|
|
149
|
+
)
|
|
150
|
+
ref = self._parse_issue_ref(result)
|
|
151
|
+
# MCP transition tool returns no data — use the key we already have
|
|
152
|
+
if not ref.key:
|
|
153
|
+
ref = IssueRef(key=key, url=ref.url)
|
|
154
|
+
return ref
|
|
155
|
+
|
|
156
|
+
# ----- Batch -----
|
|
157
|
+
|
|
158
|
+
async def batch_transition(self, keys: list[str], status: str) -> BatchResult:
|
|
159
|
+
tid = self._resolve_transition_id(status)
|
|
160
|
+
succeeded: list[IssueRef] = []
|
|
161
|
+
failed: list[FailureDetail] = []
|
|
162
|
+
|
|
163
|
+
for key in keys:
|
|
164
|
+
try:
|
|
165
|
+
result = await self._bridge.call(
|
|
166
|
+
"jira_transition_issue",
|
|
167
|
+
{"issue_key": key, "transition_id": tid},
|
|
168
|
+
)
|
|
169
|
+
ref = self._parse_issue_ref(result)
|
|
170
|
+
if not ref.key:
|
|
171
|
+
ref = IssueRef(key=key, url=ref.url)
|
|
172
|
+
succeeded.append(ref)
|
|
173
|
+
except (McpBridgeError, Exception) as exc:
|
|
174
|
+
failed.append(FailureDetail(key=key, error=str(exc)))
|
|
175
|
+
|
|
176
|
+
return BatchResult(succeeded=succeeded, failed=failed)
|
|
177
|
+
|
|
178
|
+
# ----- Relationships -----
|
|
179
|
+
|
|
180
|
+
async def link_to_parent(self, child_key: str, parent_key: str) -> None:
|
|
181
|
+
"""Link child to parent via jira_update_issue (AR-S3-2)."""
|
|
182
|
+
await self._bridge.call(
|
|
183
|
+
"jira_update_issue",
|
|
184
|
+
{
|
|
185
|
+
"issue_key": child_key,
|
|
186
|
+
"additional_fields": json.dumps({"parent": parent_key}),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def link_issues(self, source: str, target: str, link_type: str) -> None:
|
|
191
|
+
await self._bridge.call(
|
|
192
|
+
"jira_create_issue_link",
|
|
193
|
+
{
|
|
194
|
+
"inward_issue_key": source,
|
|
195
|
+
"outward_issue_key": target,
|
|
196
|
+
"link_type": link_type,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# ----- Comments -----
|
|
201
|
+
|
|
202
|
+
async def add_comment(self, key: str, body: str) -> CommentRef:
|
|
203
|
+
result = await self._bridge.call(
|
|
204
|
+
"jira_add_comment",
|
|
205
|
+
{"issue_key": key, "body": body},
|
|
206
|
+
)
|
|
207
|
+
comment_id = result.data.get("id", "")
|
|
208
|
+
url = result.data.get("self", "")
|
|
209
|
+
return CommentRef(id=str(comment_id), url=str(url))
|
|
210
|
+
|
|
211
|
+
async def get_comments(self, key: str, limit: int = 10) -> list[Comment]:
|
|
212
|
+
"""Get comments via jira_get_issue with comment_limit (AR-S3-5)."""
|
|
213
|
+
result = await self._bridge.call(
|
|
214
|
+
"jira_get_issue",
|
|
215
|
+
{"issue_key": key, "comment_limit": limit},
|
|
216
|
+
)
|
|
217
|
+
return self._parse_comments(result)
|
|
218
|
+
|
|
219
|
+
# ----- Query -----
|
|
220
|
+
|
|
221
|
+
async def search(self, query: str, limit: int = 50) -> list[IssueSummary]:
|
|
222
|
+
# Sanitize shell-escaped operators (RAISE-435: Claude Code Bash escapes ! to \!)
|
|
223
|
+
clean_query = query.replace("\\!", "!")
|
|
224
|
+
result = await self._bridge.call(
|
|
225
|
+
"jira_search",
|
|
226
|
+
{"jql": clean_query, "limit": limit},
|
|
227
|
+
)
|
|
228
|
+
return self._parse_search_results(result)
|
|
229
|
+
|
|
230
|
+
# ----- Lifecycle -----
|
|
231
|
+
|
|
232
|
+
async def aclose(self) -> None:
|
|
233
|
+
"""Close the underlying MCP bridge (RAISE-324)."""
|
|
234
|
+
await self._bridge.aclose()
|
|
235
|
+
|
|
236
|
+
# ----- Health -----
|
|
237
|
+
|
|
238
|
+
async def health(self) -> AdapterHealth:
|
|
239
|
+
start = time.monotonic()
|
|
240
|
+
try:
|
|
241
|
+
await self._bridge.call(
|
|
242
|
+
"jira_search",
|
|
243
|
+
{"jql": "project is not EMPTY", "limit": 1},
|
|
244
|
+
)
|
|
245
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
246
|
+
return AdapterHealth(
|
|
247
|
+
name="jira",
|
|
248
|
+
healthy=True,
|
|
249
|
+
message="OK",
|
|
250
|
+
latency_ms=elapsed_ms,
|
|
251
|
+
)
|
|
252
|
+
except (McpBridgeError, Exception) as exc:
|
|
253
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
254
|
+
return AdapterHealth(
|
|
255
|
+
name="jira",
|
|
256
|
+
healthy=False,
|
|
257
|
+
message=str(exc),
|
|
258
|
+
latency_ms=elapsed_ms,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# ----- Response parsers -----
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _extract_type_name(raw: Any) -> str:
|
|
265
|
+
"""Extract issue type name from raw response field."""
|
|
266
|
+
if isinstance(raw, dict):
|
|
267
|
+
name: str = str(raw.get("name", "")) # type: ignore[union-attr]
|
|
268
|
+
return name
|
|
269
|
+
return str(raw) if raw else ""
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _parse_issue_ref(result: McpToolResult) -> IssueRef:
|
|
273
|
+
"""Parse McpToolResult into IssueRef.
|
|
274
|
+
|
|
275
|
+
Handles both flat format (key at top level) and wrapped format
|
|
276
|
+
(key nested under ``issue`` — used by create_issue, update_issue).
|
|
277
|
+
"""
|
|
278
|
+
data = result.data
|
|
279
|
+
# Wrapped format: {"message": "...", "issue": {"key": "..."}}
|
|
280
|
+
if "issue" in data and isinstance(data["issue"], dict):
|
|
281
|
+
data = cast(dict[str, Any], data["issue"])
|
|
282
|
+
key = data.get("key", "")
|
|
283
|
+
url = data.get("url", data.get("self", ""))
|
|
284
|
+
return IssueRef(key=key, url=str(url))
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _parse_issue_detail(result: McpToolResult) -> IssueDetail:
|
|
288
|
+
"""Parse McpToolResult into IssueDetail.
|
|
289
|
+
|
|
290
|
+
Supports both sooperset mcp-atlassian format (top-level fields,
|
|
291
|
+
``issue_type``, ``display_name``) and raw Jira API format
|
|
292
|
+
(nested ``fields``, ``issuetype``, ``displayName``).
|
|
293
|
+
"""
|
|
294
|
+
data = result.data
|
|
295
|
+
fields = data.get("fields", {})
|
|
296
|
+
# Sooperset: top-level; raw Jira API: nested under fields
|
|
297
|
+
is_flat = "summary" in data and "fields" not in data
|
|
298
|
+
|
|
299
|
+
if is_flat:
|
|
300
|
+
assignee = data.get("assignee")
|
|
301
|
+
priority = data.get("priority")
|
|
302
|
+
parent = data.get("parent")
|
|
303
|
+
type_name = McpJiraAdapter._extract_type_name(data.get("issue_type"))
|
|
304
|
+
return IssueDetail(
|
|
305
|
+
key=data.get("key", ""),
|
|
306
|
+
url=data.get("url", ""),
|
|
307
|
+
summary=data.get("summary", ""),
|
|
308
|
+
description=data.get("description", ""),
|
|
309
|
+
status=data.get("status", {}).get("name", ""),
|
|
310
|
+
issue_type=type_name,
|
|
311
|
+
parent_key=parent.get("key") if parent else None,
|
|
312
|
+
labels=data.get("labels", []),
|
|
313
|
+
assignee=str(
|
|
314
|
+
assignee.get("display_name", assignee.get("displayName", ""))
|
|
315
|
+
)
|
|
316
|
+
if assignee
|
|
317
|
+
else None,
|
|
318
|
+
priority=priority.get("name") if priority else None,
|
|
319
|
+
created=data.get("created", ""),
|
|
320
|
+
updated=data.get("updated", ""),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Raw Jira API format (nested fields)
|
|
324
|
+
parent = fields.get("parent")
|
|
325
|
+
assignee = fields.get("assignee")
|
|
326
|
+
priority = fields.get("priority")
|
|
327
|
+
return IssueDetail(
|
|
328
|
+
key=data.get("key", ""),
|
|
329
|
+
summary=fields.get("summary", ""),
|
|
330
|
+
description=fields.get("description", ""),
|
|
331
|
+
status=fields.get("status", {}).get("name", ""),
|
|
332
|
+
issue_type=fields.get("issuetype", {}).get("name", ""),
|
|
333
|
+
parent_key=parent.get("key") if parent else None,
|
|
334
|
+
labels=fields.get("labels", []),
|
|
335
|
+
assignee=assignee.get("displayName") if assignee else None,
|
|
336
|
+
priority=priority.get("name") if priority else None,
|
|
337
|
+
created=fields.get("created", ""),
|
|
338
|
+
updated=fields.get("updated", ""),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _parse_comments(result: McpToolResult) -> list[Comment]:
|
|
343
|
+
"""Parse comments from jira_get_issue response.
|
|
344
|
+
|
|
345
|
+
Sooperset: ``data.comments`` list.
|
|
346
|
+
Raw Jira: ``data.fields.comment.comments`` list.
|
|
347
|
+
"""
|
|
348
|
+
data = result.data
|
|
349
|
+
# Sooperset format: top-level comments list
|
|
350
|
+
comments_list = data.get("comments", [])
|
|
351
|
+
if not comments_list:
|
|
352
|
+
# Raw Jira API format
|
|
353
|
+
fields = data.get("fields", {})
|
|
354
|
+
comment_data = fields.get("comment", {})
|
|
355
|
+
comments_list = comment_data.get("comments", [])
|
|
356
|
+
|
|
357
|
+
return [
|
|
358
|
+
Comment(
|
|
359
|
+
id=str(c.get("id", "")),
|
|
360
|
+
body=c.get("body", ""),
|
|
361
|
+
author=c.get("author", {}).get(
|
|
362
|
+
"display_name", c.get("author", {}).get("displayName", "")
|
|
363
|
+
),
|
|
364
|
+
created=c.get("created", ""),
|
|
365
|
+
)
|
|
366
|
+
for c in comments_list
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _parse_search_results(result: McpToolResult) -> list[IssueSummary]:
|
|
371
|
+
"""Parse search results into IssueSummary list.
|
|
372
|
+
|
|
373
|
+
Sooperset: issues have top-level fields (``summary``, ``status.name``).
|
|
374
|
+
Raw Jira: issues have nested ``fields`` dict.
|
|
375
|
+
"""
|
|
376
|
+
issues = result.data.get("issues", [])
|
|
377
|
+
summaries: list[IssueSummary] = []
|
|
378
|
+
for issue in issues:
|
|
379
|
+
fields = issue.get("fields", {})
|
|
380
|
+
is_flat = "summary" in issue and "fields" not in issue
|
|
381
|
+
|
|
382
|
+
if is_flat:
|
|
383
|
+
type_name = McpJiraAdapter._extract_type_name(issue.get("issue_type"))
|
|
384
|
+
parent = issue.get("parent")
|
|
385
|
+
summaries.append(
|
|
386
|
+
IssueSummary(
|
|
387
|
+
key=issue.get("key", ""),
|
|
388
|
+
summary=issue.get("summary", ""),
|
|
389
|
+
status=issue.get("status", {}).get("name", ""),
|
|
390
|
+
issue_type=type_name,
|
|
391
|
+
parent_key=parent.get("key") if parent else None,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
parent = fields.get("parent")
|
|
396
|
+
summaries.append(
|
|
397
|
+
IssueSummary(
|
|
398
|
+
key=issue.get("key", ""),
|
|
399
|
+
summary=fields.get("summary", ""),
|
|
400
|
+
status=fields.get("status", {}).get("name", ""),
|
|
401
|
+
issue_type=fields.get("issuetype", {}).get("name", ""),
|
|
402
|
+
parent_key=parent.get("key") if parent else None,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
return summaries
|