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.
Files changed (264) hide show
  1. raise_cli/__init__.py +38 -0
  2. raise_cli/__main__.py +30 -0
  3. raise_cli/adapters/__init__.py +91 -0
  4. raise_cli/adapters/declarative/__init__.py +26 -0
  5. raise_cli/adapters/declarative/adapter.py +267 -0
  6. raise_cli/adapters/declarative/discovery.py +94 -0
  7. raise_cli/adapters/declarative/expressions.py +150 -0
  8. raise_cli/adapters/declarative/reference/__init__.py +1 -0
  9. raise_cli/adapters/declarative/reference/github.yaml +143 -0
  10. raise_cli/adapters/declarative/schema.py +98 -0
  11. raise_cli/adapters/filesystem.py +299 -0
  12. raise_cli/adapters/mcp_bridge.py +10 -0
  13. raise_cli/adapters/mcp_confluence.py +246 -0
  14. raise_cli/adapters/mcp_jira.py +405 -0
  15. raise_cli/adapters/models.py +205 -0
  16. raise_cli/adapters/protocols.py +180 -0
  17. raise_cli/adapters/registry.py +90 -0
  18. raise_cli/adapters/sync.py +149 -0
  19. raise_cli/agents/__init__.py +14 -0
  20. raise_cli/agents/antigravity.yaml +8 -0
  21. raise_cli/agents/claude.yaml +8 -0
  22. raise_cli/agents/copilot.yaml +8 -0
  23. raise_cli/agents/copilot_plugin.py +124 -0
  24. raise_cli/agents/cursor.yaml +7 -0
  25. raise_cli/agents/roo.yaml +8 -0
  26. raise_cli/agents/windsurf.yaml +8 -0
  27. raise_cli/artifacts/__init__.py +30 -0
  28. raise_cli/artifacts/models.py +43 -0
  29. raise_cli/artifacts/reader.py +55 -0
  30. raise_cli/artifacts/renderer.py +104 -0
  31. raise_cli/artifacts/story_design.py +69 -0
  32. raise_cli/artifacts/writer.py +45 -0
  33. raise_cli/backlog/__init__.py +1 -0
  34. raise_cli/backlog/sync.py +115 -0
  35. raise_cli/cli/__init__.py +3 -0
  36. raise_cli/cli/commands/__init__.py +3 -0
  37. raise_cli/cli/commands/_resolve.py +153 -0
  38. raise_cli/cli/commands/adapters.py +362 -0
  39. raise_cli/cli/commands/artifact.py +137 -0
  40. raise_cli/cli/commands/backlog.py +333 -0
  41. raise_cli/cli/commands/base.py +31 -0
  42. raise_cli/cli/commands/discover.py +551 -0
  43. raise_cli/cli/commands/docs.py +130 -0
  44. raise_cli/cli/commands/doctor.py +177 -0
  45. raise_cli/cli/commands/gate.py +223 -0
  46. raise_cli/cli/commands/graph.py +1086 -0
  47. raise_cli/cli/commands/info.py +81 -0
  48. raise_cli/cli/commands/init.py +746 -0
  49. raise_cli/cli/commands/journal.py +167 -0
  50. raise_cli/cli/commands/mcp.py +524 -0
  51. raise_cli/cli/commands/memory.py +467 -0
  52. raise_cli/cli/commands/pattern.py +348 -0
  53. raise_cli/cli/commands/profile.py +59 -0
  54. raise_cli/cli/commands/publish.py +80 -0
  55. raise_cli/cli/commands/release.py +338 -0
  56. raise_cli/cli/commands/session.py +528 -0
  57. raise_cli/cli/commands/signal.py +410 -0
  58. raise_cli/cli/commands/skill.py +350 -0
  59. raise_cli/cli/commands/skill_set.py +145 -0
  60. raise_cli/cli/error_handler.py +158 -0
  61. raise_cli/cli/main.py +163 -0
  62. raise_cli/compat.py +66 -0
  63. raise_cli/config/__init__.py +41 -0
  64. raise_cli/config/agent_plugin.py +105 -0
  65. raise_cli/config/agent_registry.py +233 -0
  66. raise_cli/config/agents.py +120 -0
  67. raise_cli/config/ide.py +32 -0
  68. raise_cli/config/paths.py +379 -0
  69. raise_cli/config/settings.py +180 -0
  70. raise_cli/context/__init__.py +42 -0
  71. raise_cli/context/analyzers/__init__.py +16 -0
  72. raise_cli/context/analyzers/models.py +36 -0
  73. raise_cli/context/analyzers/protocol.py +43 -0
  74. raise_cli/context/analyzers/python.py +292 -0
  75. raise_cli/context/builder.py +1569 -0
  76. raise_cli/context/diff.py +213 -0
  77. raise_cli/context/extractors/__init__.py +13 -0
  78. raise_cli/context/extractors/skills.py +121 -0
  79. raise_cli/core/__init__.py +37 -0
  80. raise_cli/core/files.py +66 -0
  81. raise_cli/core/text.py +174 -0
  82. raise_cli/core/tools.py +441 -0
  83. raise_cli/discovery/__init__.py +50 -0
  84. raise_cli/discovery/analyzer.py +691 -0
  85. raise_cli/discovery/drift.py +355 -0
  86. raise_cli/discovery/scanner.py +1687 -0
  87. raise_cli/doctor/__init__.py +4 -0
  88. raise_cli/doctor/checks/__init__.py +1 -0
  89. raise_cli/doctor/checks/environment.py +110 -0
  90. raise_cli/doctor/checks/project.py +238 -0
  91. raise_cli/doctor/fix.py +80 -0
  92. raise_cli/doctor/models.py +56 -0
  93. raise_cli/doctor/protocol.py +43 -0
  94. raise_cli/doctor/registry.py +100 -0
  95. raise_cli/doctor/report.py +141 -0
  96. raise_cli/doctor/runner.py +95 -0
  97. raise_cli/engines/__init__.py +3 -0
  98. raise_cli/exceptions.py +215 -0
  99. raise_cli/gates/__init__.py +19 -0
  100. raise_cli/gates/builtin/__init__.py +1 -0
  101. raise_cli/gates/builtin/coverage.py +52 -0
  102. raise_cli/gates/builtin/lint.py +48 -0
  103. raise_cli/gates/builtin/tests.py +48 -0
  104. raise_cli/gates/builtin/types.py +48 -0
  105. raise_cli/gates/models.py +40 -0
  106. raise_cli/gates/protocol.py +41 -0
  107. raise_cli/gates/registry.py +141 -0
  108. raise_cli/governance/__init__.py +11 -0
  109. raise_cli/governance/extractor.py +412 -0
  110. raise_cli/governance/models.py +134 -0
  111. raise_cli/governance/parsers/__init__.py +35 -0
  112. raise_cli/governance/parsers/_convert.py +38 -0
  113. raise_cli/governance/parsers/adr.py +274 -0
  114. raise_cli/governance/parsers/backlog.py +356 -0
  115. raise_cli/governance/parsers/constitution.py +119 -0
  116. raise_cli/governance/parsers/epic.py +323 -0
  117. raise_cli/governance/parsers/glossary.py +316 -0
  118. raise_cli/governance/parsers/guardrails.py +345 -0
  119. raise_cli/governance/parsers/prd.py +112 -0
  120. raise_cli/governance/parsers/roadmap.py +118 -0
  121. raise_cli/governance/parsers/vision.py +116 -0
  122. raise_cli/graph/__init__.py +1 -0
  123. raise_cli/graph/backends/__init__.py +57 -0
  124. raise_cli/graph/backends/api.py +137 -0
  125. raise_cli/graph/backends/dual.py +139 -0
  126. raise_cli/graph/backends/pending.py +84 -0
  127. raise_cli/handlers/__init__.py +3 -0
  128. raise_cli/hooks/__init__.py +54 -0
  129. raise_cli/hooks/builtin/__init__.py +1 -0
  130. raise_cli/hooks/builtin/backlog.py +216 -0
  131. raise_cli/hooks/builtin/gate_bridge.py +83 -0
  132. raise_cli/hooks/builtin/jira_sync.py +127 -0
  133. raise_cli/hooks/builtin/memory.py +117 -0
  134. raise_cli/hooks/builtin/telemetry.py +72 -0
  135. raise_cli/hooks/emitter.py +184 -0
  136. raise_cli/hooks/events.py +262 -0
  137. raise_cli/hooks/protocol.py +38 -0
  138. raise_cli/hooks/registry.py +117 -0
  139. raise_cli/mcp/__init__.py +33 -0
  140. raise_cli/mcp/bridge.py +218 -0
  141. raise_cli/mcp/models.py +43 -0
  142. raise_cli/mcp/registry.py +77 -0
  143. raise_cli/mcp/schema.py +41 -0
  144. raise_cli/memory/__init__.py +58 -0
  145. raise_cli/memory/loader.py +247 -0
  146. raise_cli/memory/migration.py +241 -0
  147. raise_cli/memory/models.py +169 -0
  148. raise_cli/memory/writer.py +598 -0
  149. raise_cli/onboarding/__init__.py +103 -0
  150. raise_cli/onboarding/bootstrap.py +324 -0
  151. raise_cli/onboarding/claudemd.py +17 -0
  152. raise_cli/onboarding/conventions.py +742 -0
  153. raise_cli/onboarding/detection.py +374 -0
  154. raise_cli/onboarding/governance.py +443 -0
  155. raise_cli/onboarding/instructions.py +672 -0
  156. raise_cli/onboarding/manifest.py +201 -0
  157. raise_cli/onboarding/memory_md.py +399 -0
  158. raise_cli/onboarding/migration.py +207 -0
  159. raise_cli/onboarding/profile.py +624 -0
  160. raise_cli/onboarding/skill_conflict.py +100 -0
  161. raise_cli/onboarding/skill_manifest.py +176 -0
  162. raise_cli/onboarding/skills.py +437 -0
  163. raise_cli/onboarding/workflows.py +101 -0
  164. raise_cli/output/__init__.py +28 -0
  165. raise_cli/output/console.py +394 -0
  166. raise_cli/output/formatters/__init__.py +9 -0
  167. raise_cli/output/formatters/adapters.py +135 -0
  168. raise_cli/output/formatters/discover.py +439 -0
  169. raise_cli/output/formatters/skill.py +298 -0
  170. raise_cli/publish/__init__.py +3 -0
  171. raise_cli/publish/changelog.py +80 -0
  172. raise_cli/publish/check.py +179 -0
  173. raise_cli/publish/version.py +172 -0
  174. raise_cli/rai_base/__init__.py +22 -0
  175. raise_cli/rai_base/framework/__init__.py +7 -0
  176. raise_cli/rai_base/framework/methodology.yaml +233 -0
  177. raise_cli/rai_base/governance/__init__.py +1 -0
  178. raise_cli/rai_base/governance/architecture/__init__.py +1 -0
  179. raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
  180. raise_cli/rai_base/governance/architecture/system-context.md +34 -0
  181. raise_cli/rai_base/governance/architecture/system-design.md +24 -0
  182. raise_cli/rai_base/governance/backlog.md +8 -0
  183. raise_cli/rai_base/governance/guardrails.md +17 -0
  184. raise_cli/rai_base/governance/prd.md +25 -0
  185. raise_cli/rai_base/governance/vision.md +16 -0
  186. raise_cli/rai_base/identity/__init__.py +8 -0
  187. raise_cli/rai_base/identity/core.md +119 -0
  188. raise_cli/rai_base/identity/perspective.md +119 -0
  189. raise_cli/rai_base/memory/__init__.py +7 -0
  190. raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
  191. raise_cli/schemas/__init__.py +3 -0
  192. raise_cli/schemas/journal.py +49 -0
  193. raise_cli/schemas/session_state.py +117 -0
  194. raise_cli/session/__init__.py +5 -0
  195. raise_cli/session/bundle.py +820 -0
  196. raise_cli/session/close.py +268 -0
  197. raise_cli/session/journal.py +119 -0
  198. raise_cli/session/resolver.py +126 -0
  199. raise_cli/session/state.py +187 -0
  200. raise_cli/skills/__init__.py +44 -0
  201. raise_cli/skills/locator.py +141 -0
  202. raise_cli/skills/name_checker.py +199 -0
  203. raise_cli/skills/parser.py +145 -0
  204. raise_cli/skills/scaffold.py +212 -0
  205. raise_cli/skills/schema.py +132 -0
  206. raise_cli/skills/skillsets.py +195 -0
  207. raise_cli/skills/validator.py +197 -0
  208. raise_cli/skills_base/__init__.py +80 -0
  209. raise_cli/skills_base/contract-template.md +60 -0
  210. raise_cli/skills_base/preamble.md +37 -0
  211. raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
  212. raise_cli/skills_base/rai-debug/SKILL.md +171 -0
  213. raise_cli/skills_base/rai-discover/SKILL.md +167 -0
  214. raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
  215. raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
  216. raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
  217. raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
  218. raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
  219. raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
  220. raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
  221. raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
  222. raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
  223. raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
  224. raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
  225. raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
  226. raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
  227. raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
  228. raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
  229. raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
  230. raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
  231. raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
  232. raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
  233. raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
  234. raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
  235. raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
  236. raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
  237. raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
  238. raise_cli/skills_base/rai-research/SKILL.md +143 -0
  239. raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
  240. raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
  241. raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
  242. raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
  243. raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
  244. raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
  245. raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
  246. raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
  247. raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
  248. raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
  249. raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
  250. raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
  251. raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
  252. raise_cli/telemetry/__init__.py +42 -0
  253. raise_cli/telemetry/schemas.py +285 -0
  254. raise_cli/telemetry/writer.py +217 -0
  255. raise_cli/tier/__init__.py +0 -0
  256. raise_cli/tier/context.py +134 -0
  257. raise_cli/viz/__init__.py +7 -0
  258. raise_cli/viz/generator.py +406 -0
  259. raise_cli-2.2.1.dist-info/METADATA +433 -0
  260. raise_cli-2.2.1.dist-info/RECORD +264 -0
  261. raise_cli-2.2.1.dist-info/WHEEL +4 -0
  262. raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
  263. raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
  264. 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