julee 0.1.5__py3-none-any.whl → 0.1.6__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.
- julee/docs/sphinx_hcd/__init__.py +146 -13
- julee/docs/sphinx_hcd/domain/__init__.py +5 -0
- julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
- julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
- julee/docs/sphinx_hcd/domain/models/app.py +151 -0
- julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
- julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
- julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
- julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
- julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
- julee/docs/sphinx_hcd/domain/models/story.py +128 -0
- julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
- julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
- julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
- julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
- julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
- julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
- julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
- julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
- julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
- julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
- julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
- julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
- julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
- julee/docs/sphinx_hcd/parsers/ast.py +150 -0
- julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
- julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
- julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
- julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
- julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
- julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
- julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
- julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
- julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
- julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
- julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
- julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
- julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
- julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
- julee/docs/sphinx_hcd/sphinx/context.py +163 -0
- julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
- julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
- julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
- julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
- julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
- julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
- julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
- julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
- julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
- julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
- julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
- julee/docs/sphinx_hcd/tests/__init__.py +9 -0
- julee/docs/sphinx_hcd/tests/conftest.py +6 -0
- julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
- julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
- julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
- julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
- julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
- julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
- julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
- julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
- julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
- julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
- julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
- julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
- julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
- julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
- julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
- julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
- julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
- julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
- {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/METADATA +2 -1
- {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/RECORD +98 -13
- julee/docs/sphinx_hcd/accelerators.py +0 -1175
- julee/docs/sphinx_hcd/apps.py +0 -518
- julee/docs/sphinx_hcd/epics.py +0 -453
- julee/docs/sphinx_hcd/integrations.py +0 -310
- julee/docs/sphinx_hcd/journeys.py +0 -797
- julee/docs/sphinx_hcd/personas.py +0 -457
- julee/docs/sphinx_hcd/stories.py +0 -960
- {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
- {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Epic directives for sphinx_hcd.
|
|
2
|
+
|
|
3
|
+
Provides directives for defining and cross-referencing epics:
|
|
4
|
+
- define-epic: Define an epic with description
|
|
5
|
+
- epic-story: Reference a story as part of the epic
|
|
6
|
+
- epic-index: Render index of all epics
|
|
7
|
+
- epics-for-persona: List epics for a persona (derived from stories)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from docutils import nodes
|
|
11
|
+
|
|
12
|
+
from ...domain.models.epic import Epic
|
|
13
|
+
from ...domain.use_cases import derive_personas, get_epics_for_persona
|
|
14
|
+
from ...utils import normalize_name, path_to_root
|
|
15
|
+
from .base import HCDDirective
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EpicIndexPlaceholder(nodes.General, nodes.Element):
|
|
19
|
+
"""Placeholder node for epic index, replaced at doctree-resolved."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EpicsForPersonaPlaceholder(nodes.General, nodes.Element):
|
|
25
|
+
"""Placeholder node for epics-for-persona, replaced at doctree-resolved."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DefineEpicDirective(HCDDirective):
|
|
31
|
+
"""Define an epic with description.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
.. define-epic:: credential-creation
|
|
36
|
+
|
|
37
|
+
Covers the creation, attachment, and verification of UNTP-compliant
|
|
38
|
+
credentials including DPPs, DFRs, and DCCs.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
required_arguments = 1 # epic slug
|
|
42
|
+
has_content = True
|
|
43
|
+
option_spec = {}
|
|
44
|
+
|
|
45
|
+
def run(self):
|
|
46
|
+
epic_slug = self.arguments[0]
|
|
47
|
+
docname = self.env.docname
|
|
48
|
+
description = "\n".join(self.content).strip()
|
|
49
|
+
|
|
50
|
+
# Create and register the epic entity
|
|
51
|
+
epic = Epic(
|
|
52
|
+
slug=epic_slug,
|
|
53
|
+
description=description,
|
|
54
|
+
story_refs=[], # Will be populated by epic-story
|
|
55
|
+
docname=docname,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Add to repository
|
|
59
|
+
self.hcd_context.epic_repo.save(epic)
|
|
60
|
+
|
|
61
|
+
# Track current epic in environment for epic-story
|
|
62
|
+
if not hasattr(self.env, "epic_current"):
|
|
63
|
+
self.env.epic_current = {}
|
|
64
|
+
self.env.epic_current[docname] = epic_slug
|
|
65
|
+
|
|
66
|
+
# Build output nodes
|
|
67
|
+
result_nodes = []
|
|
68
|
+
|
|
69
|
+
if description:
|
|
70
|
+
desc_para = nodes.paragraph(text=description)
|
|
71
|
+
result_nodes.append(desc_para)
|
|
72
|
+
|
|
73
|
+
# Add a placeholder for stories (filled in doctree-resolved)
|
|
74
|
+
stories_placeholder = nodes.container()
|
|
75
|
+
stories_placeholder["classes"].append("epic-stories-placeholder")
|
|
76
|
+
stories_placeholder["epic_slug"] = epic_slug
|
|
77
|
+
result_nodes.append(stories_placeholder)
|
|
78
|
+
|
|
79
|
+
return result_nodes
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class EpicStoryDirective(HCDDirective):
|
|
83
|
+
"""Reference a story as part of the epic.
|
|
84
|
+
|
|
85
|
+
Usage::
|
|
86
|
+
|
|
87
|
+
.. epic-story:: Create DPP from Product Sheet
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
required_arguments = 1
|
|
91
|
+
final_argument_whitespace = True
|
|
92
|
+
|
|
93
|
+
def run(self):
|
|
94
|
+
story_title = self.arguments[0]
|
|
95
|
+
docname = self.env.docname
|
|
96
|
+
|
|
97
|
+
# Get current epic
|
|
98
|
+
epic_current = getattr(self.env, "epic_current", {})
|
|
99
|
+
epic_slug = epic_current.get(docname)
|
|
100
|
+
|
|
101
|
+
if epic_slug:
|
|
102
|
+
# Get the epic from repository and update story_refs
|
|
103
|
+
epic = self.hcd_context.epic_repo.get(epic_slug)
|
|
104
|
+
if epic:
|
|
105
|
+
# Add story to epic's story_refs
|
|
106
|
+
if story_title not in epic.story_refs:
|
|
107
|
+
epic.story_refs.append(story_title)
|
|
108
|
+
|
|
109
|
+
# Return empty - rendering happens in doctree-resolved
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class EpicIndexDirective(HCDDirective):
|
|
114
|
+
"""Render index of all epics.
|
|
115
|
+
|
|
116
|
+
Usage::
|
|
117
|
+
|
|
118
|
+
.. epic-index::
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def run(self):
|
|
122
|
+
# Return placeholder - actual rendering in doctree-resolved
|
|
123
|
+
node = EpicIndexPlaceholder()
|
|
124
|
+
return [node]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class EpicsForPersonaDirective(HCDDirective):
|
|
128
|
+
"""List epics for a specific persona (derived from stories).
|
|
129
|
+
|
|
130
|
+
Usage::
|
|
131
|
+
|
|
132
|
+
.. epics-for-persona:: Member Implementer
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
required_arguments = 1
|
|
136
|
+
final_argument_whitespace = True
|
|
137
|
+
|
|
138
|
+
def run(self):
|
|
139
|
+
# Return placeholder - actual rendering in doctree-resolved
|
|
140
|
+
node = EpicsForPersonaPlaceholder()
|
|
141
|
+
node["persona"] = self.arguments[0]
|
|
142
|
+
return [node]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render_epic_stories(epic: Epic, docname: str, hcd_context):
|
|
146
|
+
"""Render epic stories as a simple bullet list."""
|
|
147
|
+
from ...config import get_config
|
|
148
|
+
|
|
149
|
+
config = get_config()
|
|
150
|
+
prefix = path_to_root(docname)
|
|
151
|
+
|
|
152
|
+
# Get all stories
|
|
153
|
+
all_stories = hcd_context.story_repo.list_all()
|
|
154
|
+
all_apps = hcd_context.app_repo.list_all()
|
|
155
|
+
known_apps = {normalize_name(a.name) for a in all_apps}
|
|
156
|
+
|
|
157
|
+
# Find stories referenced by this epic
|
|
158
|
+
stories_data = []
|
|
159
|
+
for story_title in epic.story_refs:
|
|
160
|
+
story_normalized = normalize_name(story_title)
|
|
161
|
+
for story in all_stories:
|
|
162
|
+
if normalize_name(story.feature_title) == story_normalized:
|
|
163
|
+
stories_data.append(story)
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
if not stories_data:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
result_nodes = []
|
|
170
|
+
|
|
171
|
+
# Stories heading
|
|
172
|
+
stories_heading = nodes.paragraph()
|
|
173
|
+
stories_heading += nodes.strong(text="Stories")
|
|
174
|
+
result_nodes.append(stories_heading)
|
|
175
|
+
|
|
176
|
+
# Simple bullet list
|
|
177
|
+
story_list = nodes.bullet_list()
|
|
178
|
+
|
|
179
|
+
for story in sorted(stories_data, key=lambda s: s.feature_title.lower()):
|
|
180
|
+
story_item = nodes.list_item()
|
|
181
|
+
story_para = nodes.paragraph()
|
|
182
|
+
|
|
183
|
+
# Build story link manually
|
|
184
|
+
story_doc = f"{config.get_doc_path('stories')}/{story.app_slug}"
|
|
185
|
+
story_ref_uri = _build_relative_uri(docname, story_doc, story.slug)
|
|
186
|
+
story_ref = nodes.reference("", "", refuri=story_ref_uri)
|
|
187
|
+
story_ref += nodes.Text(story.i_want)
|
|
188
|
+
story_para += story_ref
|
|
189
|
+
|
|
190
|
+
# App in parentheses
|
|
191
|
+
story_para += nodes.Text(" (")
|
|
192
|
+
app_path = (
|
|
193
|
+
f"{prefix}{config.get_doc_path('applications')}/{story.app_slug}.html"
|
|
194
|
+
)
|
|
195
|
+
app_valid = story.app_normalized in known_apps
|
|
196
|
+
|
|
197
|
+
if app_valid:
|
|
198
|
+
app_ref = nodes.reference("", "", refuri=app_path)
|
|
199
|
+
app_ref += nodes.Text(story.app_slug.replace("-", " ").title())
|
|
200
|
+
story_para += app_ref
|
|
201
|
+
else:
|
|
202
|
+
story_para += nodes.Text(story.app_slug.replace("-", " ").title())
|
|
203
|
+
|
|
204
|
+
story_para += nodes.Text(")")
|
|
205
|
+
|
|
206
|
+
story_item += story_para
|
|
207
|
+
story_list += story_item
|
|
208
|
+
|
|
209
|
+
result_nodes.append(story_list)
|
|
210
|
+
|
|
211
|
+
return result_nodes
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _build_relative_uri(from_docname: str, target_doc: str, anchor: str = None) -> str:
|
|
215
|
+
"""Build a relative URI from one doc to another."""
|
|
216
|
+
from_parts = from_docname.split("/")
|
|
217
|
+
target_parts = target_doc.split("/")
|
|
218
|
+
|
|
219
|
+
common = 0
|
|
220
|
+
for i in range(min(len(from_parts), len(target_parts))):
|
|
221
|
+
if from_parts[i] == target_parts[i]:
|
|
222
|
+
common += 1
|
|
223
|
+
else:
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
up_levels = len(from_parts) - common - 1
|
|
227
|
+
down_path = "/".join(target_parts[common:])
|
|
228
|
+
|
|
229
|
+
if up_levels > 0:
|
|
230
|
+
rel_path = "../" * up_levels + down_path + ".html"
|
|
231
|
+
else:
|
|
232
|
+
rel_path = down_path + ".html"
|
|
233
|
+
|
|
234
|
+
if anchor:
|
|
235
|
+
return f"{rel_path}#{anchor}"
|
|
236
|
+
return rel_path
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_epic_index(env, docname: str, hcd_context):
|
|
240
|
+
"""Build the epic index listing all epics, plus unassigned stories."""
|
|
241
|
+
from ...config import get_config
|
|
242
|
+
|
|
243
|
+
config = get_config()
|
|
244
|
+
prefix = path_to_root(docname)
|
|
245
|
+
|
|
246
|
+
all_epics = hcd_context.epic_repo.list_all()
|
|
247
|
+
all_stories = hcd_context.story_repo.list_all()
|
|
248
|
+
all_apps = hcd_context.app_repo.list_all()
|
|
249
|
+
known_apps = {normalize_name(a.name) for a in all_apps}
|
|
250
|
+
|
|
251
|
+
if not all_epics:
|
|
252
|
+
para = nodes.paragraph()
|
|
253
|
+
para += nodes.emphasis(text="No epics defined")
|
|
254
|
+
return [para]
|
|
255
|
+
|
|
256
|
+
result_nodes = []
|
|
257
|
+
bullet_list = nodes.bullet_list()
|
|
258
|
+
|
|
259
|
+
# Collect all stories assigned to epics
|
|
260
|
+
assigned_stories = set()
|
|
261
|
+
for epic in all_epics:
|
|
262
|
+
for story_title in epic.story_refs:
|
|
263
|
+
assigned_stories.add(normalize_name(story_title))
|
|
264
|
+
|
|
265
|
+
for epic in sorted(all_epics, key=lambda e: e.slug):
|
|
266
|
+
item = nodes.list_item()
|
|
267
|
+
para = nodes.paragraph()
|
|
268
|
+
|
|
269
|
+
# Link to epic
|
|
270
|
+
epic_path = f"{epic.slug}.html"
|
|
271
|
+
epic_ref = nodes.reference("", "", refuri=epic_path)
|
|
272
|
+
epic_ref += nodes.Text(epic.slug.replace("-", " ").title())
|
|
273
|
+
para += epic_ref
|
|
274
|
+
|
|
275
|
+
# Story count
|
|
276
|
+
story_count = len(epic.story_refs)
|
|
277
|
+
para += nodes.Text(f" ({story_count} stories)")
|
|
278
|
+
|
|
279
|
+
item += para
|
|
280
|
+
bullet_list += item
|
|
281
|
+
|
|
282
|
+
result_nodes.append(bullet_list)
|
|
283
|
+
|
|
284
|
+
# Find unassigned stories
|
|
285
|
+
unassigned_stories = []
|
|
286
|
+
for story in all_stories:
|
|
287
|
+
if normalize_name(story.feature_title) not in assigned_stories:
|
|
288
|
+
unassigned_stories.append(story)
|
|
289
|
+
|
|
290
|
+
if unassigned_stories:
|
|
291
|
+
heading = nodes.paragraph()
|
|
292
|
+
heading += nodes.strong(text="Unassigned Stories")
|
|
293
|
+
result_nodes.append(heading)
|
|
294
|
+
|
|
295
|
+
intro = nodes.paragraph()
|
|
296
|
+
intro += nodes.Text(
|
|
297
|
+
f"{len(unassigned_stories)} stories not yet assigned to an epic:"
|
|
298
|
+
)
|
|
299
|
+
result_nodes.append(intro)
|
|
300
|
+
|
|
301
|
+
unassigned_list = nodes.bullet_list()
|
|
302
|
+
for story in sorted(unassigned_stories, key=lambda s: s.feature_title.lower()):
|
|
303
|
+
item = nodes.list_item()
|
|
304
|
+
para = nodes.paragraph()
|
|
305
|
+
|
|
306
|
+
# Story link
|
|
307
|
+
story_doc = f"{config.get_doc_path('stories')}/{story.app_slug}"
|
|
308
|
+
story_ref_uri = _build_relative_uri(docname, story_doc, story.slug)
|
|
309
|
+
story_ref = nodes.reference("", "", refuri=story_ref_uri)
|
|
310
|
+
story_ref += nodes.Text(story.i_want)
|
|
311
|
+
para += story_ref
|
|
312
|
+
|
|
313
|
+
# App in parentheses
|
|
314
|
+
para += nodes.Text(" (")
|
|
315
|
+
app_path = (
|
|
316
|
+
f"{prefix}{config.get_doc_path('applications')}/{story.app_slug}.html"
|
|
317
|
+
)
|
|
318
|
+
app_valid = story.app_normalized in known_apps
|
|
319
|
+
|
|
320
|
+
if app_valid:
|
|
321
|
+
app_ref = nodes.reference("", "", refuri=app_path)
|
|
322
|
+
app_ref += nodes.Text(story.app_slug.replace("-", " ").title())
|
|
323
|
+
para += app_ref
|
|
324
|
+
else:
|
|
325
|
+
para += nodes.Text(story.app_slug.replace("-", " ").title())
|
|
326
|
+
|
|
327
|
+
para += nodes.Text(")")
|
|
328
|
+
|
|
329
|
+
item += para
|
|
330
|
+
unassigned_list += item
|
|
331
|
+
|
|
332
|
+
result_nodes.append(unassigned_list)
|
|
333
|
+
|
|
334
|
+
return result_nodes
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def build_epics_for_persona(env, docname: str, persona_arg: str, hcd_context):
|
|
338
|
+
"""Build list of epics for a persona."""
|
|
339
|
+
from ...config import get_config
|
|
340
|
+
|
|
341
|
+
config = get_config()
|
|
342
|
+
prefix = path_to_root(docname)
|
|
343
|
+
|
|
344
|
+
all_stories = hcd_context.story_repo.list_all()
|
|
345
|
+
all_epics = hcd_context.epic_repo.list_all()
|
|
346
|
+
|
|
347
|
+
# Derive personas to get their epic associations
|
|
348
|
+
personas = derive_personas(all_stories, all_epics)
|
|
349
|
+
persona_normalized = normalize_name(persona_arg)
|
|
350
|
+
|
|
351
|
+
# Find the persona
|
|
352
|
+
persona = None
|
|
353
|
+
for p in personas:
|
|
354
|
+
if p.normalized_name == persona_normalized:
|
|
355
|
+
persona = p
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
if not persona:
|
|
359
|
+
para = nodes.paragraph()
|
|
360
|
+
para += nodes.emphasis(text=f"No epics found for persona '{persona_arg}'")
|
|
361
|
+
return [para]
|
|
362
|
+
|
|
363
|
+
# Get epics for this persona
|
|
364
|
+
matching_epics = get_epics_for_persona(persona, all_epics, all_stories)
|
|
365
|
+
|
|
366
|
+
if not matching_epics:
|
|
367
|
+
para = nodes.paragraph()
|
|
368
|
+
para += nodes.emphasis(text=f"No epics found for persona '{persona_arg}'")
|
|
369
|
+
return [para]
|
|
370
|
+
|
|
371
|
+
bullet_list = nodes.bullet_list()
|
|
372
|
+
|
|
373
|
+
for epic in sorted(matching_epics, key=lambda e: e.slug):
|
|
374
|
+
item = nodes.list_item()
|
|
375
|
+
para = nodes.paragraph()
|
|
376
|
+
|
|
377
|
+
epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic.slug}.html"
|
|
378
|
+
epic_ref = nodes.reference("", "", refuri=epic_path)
|
|
379
|
+
epic_ref += nodes.Text(epic.slug.replace("-", " ").title())
|
|
380
|
+
para += epic_ref
|
|
381
|
+
|
|
382
|
+
item += para
|
|
383
|
+
bullet_list += item
|
|
384
|
+
|
|
385
|
+
return [bullet_list]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def clear_epic_state(app, env, docname):
|
|
389
|
+
"""Clear epic state when a document is re-read."""
|
|
390
|
+
from ..context import get_hcd_context
|
|
391
|
+
|
|
392
|
+
# Clear current epic tracker
|
|
393
|
+
if hasattr(env, "epic_current") and docname in env.epic_current:
|
|
394
|
+
del env.epic_current[docname]
|
|
395
|
+
|
|
396
|
+
# Clear epics from this document via repository
|
|
397
|
+
hcd_context = get_hcd_context(app)
|
|
398
|
+
hcd_context.epic_repo.run_async(
|
|
399
|
+
hcd_context.epic_repo.async_repo.clear_by_docname(docname)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def process_epic_placeholders(app, doctree, docname):
|
|
404
|
+
"""Replace epic placeholders with rendered content."""
|
|
405
|
+
from ..context import get_hcd_context
|
|
406
|
+
|
|
407
|
+
env = app.env
|
|
408
|
+
hcd_context = get_hcd_context(app)
|
|
409
|
+
epic_current = getattr(env, "epic_current", {})
|
|
410
|
+
|
|
411
|
+
# Process epic stories placeholder
|
|
412
|
+
epic_slug = epic_current.get(docname)
|
|
413
|
+
if epic_slug:
|
|
414
|
+
epic = hcd_context.epic_repo.get(epic_slug)
|
|
415
|
+
if epic:
|
|
416
|
+
for node in doctree.traverse(nodes.container):
|
|
417
|
+
if "epic-stories-placeholder" in node.get("classes", []):
|
|
418
|
+
stories_nodes = render_epic_stories(epic, docname, hcd_context)
|
|
419
|
+
if stories_nodes:
|
|
420
|
+
node.replace_self(stories_nodes)
|
|
421
|
+
else:
|
|
422
|
+
node.replace_self([])
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
# Process epic index placeholder
|
|
426
|
+
for node in doctree.traverse(EpicIndexPlaceholder):
|
|
427
|
+
index_node = build_epic_index(env, docname, hcd_context)
|
|
428
|
+
node.replace_self(index_node)
|
|
429
|
+
|
|
430
|
+
# Process epics-for-persona placeholder
|
|
431
|
+
for node in doctree.traverse(EpicsForPersonaPlaceholder):
|
|
432
|
+
persona = node["persona"]
|
|
433
|
+
epics_node = build_epics_for_persona(env, docname, persona, hcd_context)
|
|
434
|
+
node.replace_self(epics_node)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Integration directives for sphinx_hcd.
|
|
2
|
+
|
|
3
|
+
Provides directives to render integration information with external dependencies.
|
|
4
|
+
|
|
5
|
+
Provides directives:
|
|
6
|
+
- define-integration: Render integration info from YAML
|
|
7
|
+
- integration-index: Generate index with architecture diagram
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from docutils import nodes
|
|
13
|
+
|
|
14
|
+
from ...domain.models.integration import Direction
|
|
15
|
+
from .base import HCDDirective
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DefineIntegrationPlaceholder(nodes.General, nodes.Element):
|
|
19
|
+
"""Placeholder node for define-integration, replaced at doctree-resolved."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IntegrationIndexPlaceholder(nodes.General, nodes.Element):
|
|
25
|
+
"""Placeholder node for integration-index, replaced at doctree-resolved."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DefineIntegrationDirective(HCDDirective):
|
|
31
|
+
"""Render integration info from YAML manifest.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
.. define-integration:: pilot-data-collection
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
required_arguments = 1
|
|
39
|
+
|
|
40
|
+
def run(self):
|
|
41
|
+
slug = self.arguments[0]
|
|
42
|
+
|
|
43
|
+
# Track documented integrations
|
|
44
|
+
if not hasattr(self.env, "documented_integrations"):
|
|
45
|
+
self.env.documented_integrations = set()
|
|
46
|
+
self.env.documented_integrations.add(slug)
|
|
47
|
+
|
|
48
|
+
node = DefineIntegrationPlaceholder()
|
|
49
|
+
node["integration_slug"] = slug
|
|
50
|
+
return [node]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IntegrationIndexDirective(HCDDirective):
|
|
54
|
+
"""Generate integration index with architecture diagram.
|
|
55
|
+
|
|
56
|
+
Usage::
|
|
57
|
+
|
|
58
|
+
.. integration-index::
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def run(self):
|
|
62
|
+
return [IntegrationIndexPlaceholder()]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_integration_content(slug: str, docname: str, hcd_context):
|
|
66
|
+
"""Build content nodes for an integration page."""
|
|
67
|
+
from sphinx.addnodes import seealso
|
|
68
|
+
|
|
69
|
+
integration = hcd_context.integration_repo.get(slug)
|
|
70
|
+
|
|
71
|
+
if not integration:
|
|
72
|
+
para = nodes.paragraph()
|
|
73
|
+
para += nodes.problematic(text=f"Integration '{slug}' not found")
|
|
74
|
+
return [para]
|
|
75
|
+
|
|
76
|
+
result_nodes = []
|
|
77
|
+
|
|
78
|
+
# Description
|
|
79
|
+
if integration.description:
|
|
80
|
+
desc_para = nodes.paragraph()
|
|
81
|
+
desc_para += nodes.Text(integration.description)
|
|
82
|
+
result_nodes.append(desc_para)
|
|
83
|
+
|
|
84
|
+
# Seealso with metadata
|
|
85
|
+
seealso_node = seealso()
|
|
86
|
+
|
|
87
|
+
# Direction
|
|
88
|
+
direction_labels = {
|
|
89
|
+
Direction.INBOUND: "Inbound (data source)",
|
|
90
|
+
Direction.OUTBOUND: "Outbound (data sink)",
|
|
91
|
+
Direction.BIDIRECTIONAL: "Bidirectional",
|
|
92
|
+
}
|
|
93
|
+
dir_para = nodes.paragraph()
|
|
94
|
+
dir_para += nodes.strong(text="Direction: ")
|
|
95
|
+
dir_para += nodes.Text(
|
|
96
|
+
direction_labels.get(integration.direction, str(integration.direction))
|
|
97
|
+
)
|
|
98
|
+
seealso_node += dir_para
|
|
99
|
+
|
|
100
|
+
# Module
|
|
101
|
+
mod_para = nodes.paragraph()
|
|
102
|
+
mod_para += nodes.strong(text="Module: ")
|
|
103
|
+
mod_para += nodes.literal(text=f"integrations.{integration.module}")
|
|
104
|
+
seealso_node += mod_para
|
|
105
|
+
|
|
106
|
+
# External dependencies
|
|
107
|
+
if integration.depends_on:
|
|
108
|
+
deps_para = nodes.paragraph()
|
|
109
|
+
deps_para += nodes.strong(text="Depends On: ")
|
|
110
|
+
for i, dep in enumerate(integration.depends_on):
|
|
111
|
+
if dep.url:
|
|
112
|
+
ref = nodes.reference("", "", refuri=dep.url)
|
|
113
|
+
ref += nodes.Text(dep.name)
|
|
114
|
+
deps_para += ref
|
|
115
|
+
else:
|
|
116
|
+
deps_para += nodes.Text(dep.name)
|
|
117
|
+
if i < len(integration.depends_on) - 1:
|
|
118
|
+
deps_para += nodes.Text(", ")
|
|
119
|
+
seealso_node += deps_para
|
|
120
|
+
|
|
121
|
+
result_nodes.append(seealso_node)
|
|
122
|
+
return result_nodes
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_integration_index(docname: str, hcd_context):
|
|
126
|
+
"""Build integration index with architecture diagram."""
|
|
127
|
+
try:
|
|
128
|
+
from sphinxcontrib.plantuml import plantuml
|
|
129
|
+
except ImportError:
|
|
130
|
+
para = nodes.paragraph()
|
|
131
|
+
para += nodes.emphasis(text="PlantUML extension not available")
|
|
132
|
+
return [para]
|
|
133
|
+
|
|
134
|
+
all_integrations = hcd_context.integration_repo.list_all()
|
|
135
|
+
|
|
136
|
+
if not all_integrations:
|
|
137
|
+
para = nodes.paragraph()
|
|
138
|
+
para += nodes.emphasis(text="No integrations defined")
|
|
139
|
+
return [para]
|
|
140
|
+
|
|
141
|
+
# Build PlantUML diagram
|
|
142
|
+
lines = [
|
|
143
|
+
"@startuml",
|
|
144
|
+
"skinparam componentStyle rectangle",
|
|
145
|
+
"skinparam defaultTextAlignment center",
|
|
146
|
+
"skinparam component {",
|
|
147
|
+
" BackgroundColor<<integration>> LightBlue",
|
|
148
|
+
" BackgroundColor<<external>> LightYellow",
|
|
149
|
+
" BackgroundColor<<core>> LightGreen",
|
|
150
|
+
"}",
|
|
151
|
+
"",
|
|
152
|
+
'title "Integration Architecture"',
|
|
153
|
+
"",
|
|
154
|
+
"' Core system",
|
|
155
|
+
'component "Julee\\nSolution" as core <<core>>',
|
|
156
|
+
"",
|
|
157
|
+
"' Integrations and their external dependencies",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
for integration in sorted(all_integrations, key=lambda i: i.slug):
|
|
161
|
+
int_id = integration.slug.replace("-", "_")
|
|
162
|
+
lines.append(f'component "{integration.name}" as {int_id} <<integration>>')
|
|
163
|
+
|
|
164
|
+
for dep in integration.depends_on:
|
|
165
|
+
dep_id = dep.name.lower().replace(" ", "_").replace("-", "_")
|
|
166
|
+
dep_label = dep.name
|
|
167
|
+
if dep.description:
|
|
168
|
+
dep_label += f"\\n({dep.description})"
|
|
169
|
+
lines.append(f'component "{dep_label}" as {dep_id} <<external>>')
|
|
170
|
+
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append("' Relationships")
|
|
173
|
+
|
|
174
|
+
for integration in sorted(all_integrations, key=lambda i: i.slug):
|
|
175
|
+
int_id = integration.slug.replace("-", "_")
|
|
176
|
+
|
|
177
|
+
# Core to/from integration
|
|
178
|
+
if integration.direction == Direction.INBOUND:
|
|
179
|
+
lines.append(f"{int_id} --> core")
|
|
180
|
+
elif integration.direction == Direction.OUTBOUND:
|
|
181
|
+
lines.append(f"core --> {int_id}")
|
|
182
|
+
else:
|
|
183
|
+
lines.append(f"core <--> {int_id}")
|
|
184
|
+
|
|
185
|
+
# Integration to external dependencies
|
|
186
|
+
for dep in integration.depends_on:
|
|
187
|
+
dep_id = dep.name.lower().replace(" ", "_").replace("-", "_")
|
|
188
|
+
if integration.direction == Direction.INBOUND:
|
|
189
|
+
lines.append(f"{dep_id} --> {int_id}")
|
|
190
|
+
elif integration.direction == Direction.OUTBOUND:
|
|
191
|
+
lines.append(f"{int_id} --> {dep_id}")
|
|
192
|
+
else:
|
|
193
|
+
lines.append(f"{int_id} <--> {dep_id}")
|
|
194
|
+
|
|
195
|
+
lines.append("")
|
|
196
|
+
lines.append("@enduml")
|
|
197
|
+
|
|
198
|
+
puml_source = "\n".join(lines)
|
|
199
|
+
node = plantuml(puml_source)
|
|
200
|
+
node["uml"] = puml_source
|
|
201
|
+
node["incdir"] = os.path.dirname(docname)
|
|
202
|
+
node["filename"] = os.path.basename(docname) + ".rst"
|
|
203
|
+
|
|
204
|
+
return [node]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def process_integration_placeholders(app, doctree, docname):
|
|
208
|
+
"""Replace integration placeholders after all documents are read."""
|
|
209
|
+
from ..context import get_hcd_context
|
|
210
|
+
|
|
211
|
+
hcd_context = get_hcd_context(app)
|
|
212
|
+
|
|
213
|
+
for node in doctree.traverse(DefineIntegrationPlaceholder):
|
|
214
|
+
slug = node["integration_slug"]
|
|
215
|
+
content = build_integration_content(slug, docname, hcd_context)
|
|
216
|
+
node.replace_self(content)
|
|
217
|
+
|
|
218
|
+
for node in doctree.traverse(IntegrationIndexPlaceholder):
|
|
219
|
+
content = build_integration_index(docname, hcd_context)
|
|
220
|
+
node.replace_self(content)
|