ara-cli 0.1.10.5__py3-none-any.whl → 0.1.14.0__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 (151) hide show
  1. ara_cli/__init__.py +51 -6
  2. ara_cli/__main__.py +87 -75
  3. ara_cli/ara_command_action.py +189 -101
  4. ara_cli/ara_config.py +187 -128
  5. ara_cli/ara_subcommands/common.py +2 -2
  6. ara_cli/ara_subcommands/config.py +221 -0
  7. ara_cli/ara_subcommands/convert.py +107 -0
  8. ara_cli/ara_subcommands/fetch.py +41 -0
  9. ara_cli/ara_subcommands/fetch_agents.py +22 -0
  10. ara_cli/ara_subcommands/fetch_scripts.py +19 -0
  11. ara_cli/ara_subcommands/fetch_templates.py +15 -10
  12. ara_cli/ara_subcommands/list.py +97 -23
  13. ara_cli/ara_subcommands/prompt.py +266 -106
  14. ara_cli/artefact_autofix.py +117 -64
  15. ara_cli/artefact_converter.py +355 -0
  16. ara_cli/artefact_creator.py +41 -17
  17. ara_cli/artefact_lister.py +3 -3
  18. ara_cli/artefact_models/artefact_model.py +1 -1
  19. ara_cli/artefact_models/artefact_templates.py +0 -9
  20. ara_cli/artefact_models/feature_artefact_model.py +8 -8
  21. ara_cli/artefact_reader.py +62 -43
  22. ara_cli/artefact_scan.py +39 -17
  23. ara_cli/chat.py +300 -71
  24. ara_cli/chat_agent/__init__.py +0 -0
  25. ara_cli/chat_agent/agent_process_manager.py +155 -0
  26. ara_cli/chat_script_runner/__init__.py +0 -0
  27. ara_cli/chat_script_runner/script_completer.py +23 -0
  28. ara_cli/chat_script_runner/script_finder.py +41 -0
  29. ara_cli/chat_script_runner/script_lister.py +36 -0
  30. ara_cli/chat_script_runner/script_runner.py +36 -0
  31. ara_cli/chat_web_search/__init__.py +0 -0
  32. ara_cli/chat_web_search/web_search.py +263 -0
  33. ara_cli/children_contribution_updater.py +737 -0
  34. ara_cli/classifier.py +34 -0
  35. ara_cli/commands/agent_run_command.py +98 -0
  36. ara_cli/commands/fetch_agents_command.py +106 -0
  37. ara_cli/commands/fetch_scripts_command.py +43 -0
  38. ara_cli/commands/fetch_templates_command.py +39 -0
  39. ara_cli/commands/fetch_templates_commands.py +39 -0
  40. ara_cli/commands/list_agents_command.py +39 -0
  41. ara_cli/commands/load_command.py +4 -3
  42. ara_cli/commands/load_image_command.py +1 -1
  43. ara_cli/commands/read_command.py +23 -27
  44. ara_cli/completers.py +95 -35
  45. ara_cli/constants.py +2 -0
  46. ara_cli/directory_navigator.py +37 -4
  47. ara_cli/error_handler.py +26 -11
  48. ara_cli/file_loaders/document_reader.py +0 -178
  49. ara_cli/file_loaders/factories/__init__.py +0 -0
  50. ara_cli/file_loaders/factories/document_reader_factory.py +32 -0
  51. ara_cli/file_loaders/factories/file_loader_factory.py +27 -0
  52. ara_cli/file_loaders/file_loader.py +1 -30
  53. ara_cli/file_loaders/loaders/__init__.py +0 -0
  54. ara_cli/file_loaders/{document_file_loader.py → loaders/document_file_loader.py} +1 -1
  55. ara_cli/file_loaders/loaders/text_file_loader.py +47 -0
  56. ara_cli/file_loaders/readers/__init__.py +0 -0
  57. ara_cli/file_loaders/readers/docx_reader.py +49 -0
  58. ara_cli/file_loaders/readers/excel_reader.py +27 -0
  59. ara_cli/file_loaders/{markdown_reader.py → readers/markdown_reader.py} +1 -1
  60. ara_cli/file_loaders/readers/odt_reader.py +59 -0
  61. ara_cli/file_loaders/readers/pdf_reader.py +54 -0
  62. ara_cli/file_loaders/readers/pptx_reader.py +104 -0
  63. ara_cli/file_loaders/tools/__init__.py +0 -0
  64. ara_cli/llm_utils.py +58 -0
  65. ara_cli/output_suppressor.py +53 -0
  66. ara_cli/prompt_chat.py +20 -4
  67. ara_cli/prompt_extractor.py +47 -32
  68. ara_cli/prompt_handler.py +123 -17
  69. ara_cli/tag_extractor.py +8 -7
  70. ara_cli/template_loader.py +2 -1
  71. ara_cli/template_manager.py +52 -21
  72. ara_cli/templates/global-scripts/hello_global.py +1 -0
  73. ara_cli/templates/prompt-modules/commands/add_scenarios_for_new_behaviour.feature_creation_agent.commands.md +1 -0
  74. ara_cli/templates/prompt-modules/commands/align_feature_with_implementation_changes.interview_agent.commands.md +1 -0
  75. ara_cli/templates/prompt-modules/commands/analyze_codebase_and_plan_tasks.interview_agent.commands.md +1 -0
  76. ara_cli/templates/prompt-modules/commands/choose_best_parent_artefact.interview_agent.commands.md +1 -0
  77. ara_cli/templates/prompt-modules/commands/create_tasks_from_artefact_content.interview_agent.commands.md +1 -0
  78. ara_cli/templates/prompt-modules/commands/create_tests_for_uncovered_modules.test_generation_agent.commands.md +1 -0
  79. ara_cli/templates/prompt-modules/commands/derive_features_from_video_description.feature_creation_agent.commands.md +1 -0
  80. ara_cli/templates/prompt-modules/commands/describe_agent_capabilities.agent.commands.md +1 -0
  81. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  82. ara_cli/templates/prompt-modules/commands/execute_scoped_todos_in_task.interview_agent.commands.md +1 -0
  83. ara_cli/templates/prompt-modules/commands/explain_single_file_purpose.interview_agent.commands.md +1 -0
  84. ara_cli/templates/prompt-modules/commands/extract_file_information_bullets.interview_agent.commands.md +1 -0
  85. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  86. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  87. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  88. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  89. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  90. ara_cli/templates/prompt-modules/commands/fix_failing_behave_step_definitions.interview_agent.commands.md +1 -0
  91. ara_cli/templates/prompt-modules/commands/fix_failing_pytest_tests.interview_agent.commands.md +1 -0
  92. ara_cli/templates/prompt-modules/commands/general_instruction_policy.commands.md +47 -0
  93. ara_cli/templates/prompt-modules/commands/generate_and_fix_pytest_tests.test_generation_agent.commands.md +1 -0
  94. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  95. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  96. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  97. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  98. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  99. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  100. ara_cli/templates/prompt-modules/commands/suggest_next_story_child_tasks.interview_agent.commands.md +1 -0
  101. ara_cli/templates/prompt-modules/commands/summarize_or_transcribe_media.interview_agent.commands.md +1 -0
  102. ara_cli/templates/prompt-modules/commands/update_feature_to_match_implementation.feature_creation_agent.commands.md +1 -0
  103. ara_cli/templates/prompt-modules/commands/update_user_story_with_requirements.interview_agent.commands.md +1 -0
  104. ara_cli/version.py +1 -1
  105. {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/METADATA +49 -11
  106. ara_cli-0.1.14.0.dist-info/RECORD +253 -0
  107. {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/WHEEL +1 -1
  108. tests/test_ara_command_action.py +31 -19
  109. tests/test_ara_config.py +177 -90
  110. tests/test_artefact_autofix.py +170 -97
  111. tests/test_artefact_autofix_integration.py +495 -0
  112. tests/test_artefact_converter.py +312 -0
  113. tests/test_artefact_extraction.py +564 -0
  114. tests/test_artefact_lister.py +11 -8
  115. tests/test_chat.py +166 -130
  116. tests/test_chat_givens_images.py +603 -0
  117. tests/test_chat_script_runner.py +454 -0
  118. tests/test_children_contribution_updater.py +98 -0
  119. tests/test_document_loader_office.py +267 -0
  120. tests/test_llm_utils.py +164 -0
  121. tests/test_prompt_chat.py +343 -0
  122. tests/test_prompt_extractor.py +683 -0
  123. tests/test_prompt_handler.py +416 -214
  124. tests/test_setup_default_chat_prompt_mode.py +198 -0
  125. tests/test_tag_extractor.py +95 -49
  126. tests/test_web_search.py +467 -0
  127. ara_cli/file_loaders/document_readers.py +0 -233
  128. ara_cli/file_loaders/file_loaders.py +0 -123
  129. ara_cli/file_loaders/text_file_loader.py +0 -187
  130. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  131. ara_cli/templates/prompt-modules/blueprints/pytest_unittest_prompt.blueprint.md +0 -32
  132. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  133. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  134. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  135. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  136. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  137. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  138. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  139. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  140. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  141. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  142. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  143. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  144. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  145. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  146. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  147. ara_cli-0.1.10.5.dist-info/RECORD +0 -194
  148. /ara_cli/file_loaders/{binary_file_loader.py → loaders/binary_file_loader.py} +0 -0
  149. /ara_cli/file_loaders/{image_processor.py → tools/image_processor.py} +0 -0
  150. {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/entry_points.txt +0 -0
  151. {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,737 @@
1
+ """
2
+ Children Contribution Updater Module
3
+
4
+ This module handles updating children artefacts' contribution field when their parent's
5
+ classifier changes during artefact conversion. It follows Single Responsibility Principle
6
+ by separating this concern from the main ArtefactConverter class.
7
+
8
+ Supports both interactive (CLI) and non-interactive (API) modes.
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import sys
14
+ from typing import Optional, Dict, List, Tuple, Literal
15
+ from enum import Enum
16
+ from ara_cli.classifier import Classifier
17
+ from ara_cli.artefact_reader import ArtefactReader
18
+ from ara_cli.directory_navigator import DirectoryNavigator
19
+ from ara_cli.file_classifier import FileClassifier
20
+
21
+ # Import readline for tab completion (Unix) or pyreadline3 (Windows)
22
+ try:
23
+ import readline
24
+ except ImportError:
25
+ try:
26
+ import pyreadline3 as readline
27
+ except ImportError:
28
+ readline = None
29
+
30
+
31
+ class InputCompleter:
32
+ """
33
+ A simple completer for readline tab completion.
34
+ Provides completion for a list of options.
35
+ """
36
+
37
+ def __init__(self, options: List[str]):
38
+ self.options = sorted(options)
39
+ self.matches: List[str] = []
40
+
41
+ def complete(self, text: str, state: int) -> Optional[str]:
42
+ """Return the next possible completion for 'text'."""
43
+ if state == 0:
44
+ # First call for this text - compute matches
45
+ if text:
46
+ self.matches = [o for o in self.options if o.startswith(text)]
47
+ else:
48
+ self.matches = self.options[:]
49
+
50
+ try:
51
+ return self.matches[state]
52
+ except IndexError:
53
+ return None
54
+
55
+
56
+ def _setup_completer(options: List[str]) -> None:
57
+ """Setup readline completer with the given options."""
58
+ if readline is None:
59
+ return
60
+
61
+ completer = InputCompleter(options)
62
+ readline.set_completer(completer.complete)
63
+ readline.set_completer_delims(" \t\n")
64
+
65
+ # Enable tab completion
66
+ if sys.platform == "darwin":
67
+ readline.parse_and_bind("bind ^I rl_complete")
68
+ else:
69
+ readline.parse_and_bind("tab: complete")
70
+
71
+
72
+ def _clear_completer() -> None:
73
+ """Clear the readline completer."""
74
+ if readline is None:
75
+ return
76
+ readline.set_completer(None)
77
+
78
+
79
+ class ChildrenAction(str, Enum):
80
+ """Actions for handling children when converting to leaf-node types."""
81
+
82
+ CANCEL = "cancel"
83
+ CLEAR = "clear"
84
+ REASSIGN = "reassign"
85
+
86
+
87
+ class ChildrenContributionUpdater:
88
+ """
89
+ Handles updating children's contribution field when parent artefact is converted.
90
+
91
+ Responsibilities:
92
+ - Find children of a parent artefact
93
+ - Update children's contribution classifier to new parent type
94
+ - Handle edge cases when new parent type cannot have children (task/issue)
95
+ - Clear contribution rule if new parent doesn't support rules
96
+ """
97
+
98
+ # User choice constants for edge case handling
99
+ CHOICE_CANCEL = "1"
100
+ CHOICE_CLEAR_CONTRIBUTION = "2"
101
+ CHOICE_SELECT_NEW_PARENT = "3"
102
+
103
+ def __init__(self, file_system=None):
104
+ self.file_system = file_system or os
105
+
106
+ def get_children_info(
107
+ self, artefact_name: str, old_classifier: str, new_classifier: str
108
+ ) -> Dict:
109
+ """
110
+ Get information about children for preview/modal display.
111
+ Used by API to show children info before conversion.
112
+
113
+ Returns:
114
+ Dict with children info for frontend display:
115
+ {
116
+ "has_children": bool,
117
+ "children_count": int,
118
+ "children": {classifier: [{name, title}]},
119
+ "target_can_have_children": bool,
120
+ "requires_action": bool,
121
+ "valid_new_parent_classifiers": [str],
122
+ "target_exists": bool,
123
+ "message": str
124
+ }
125
+ """
126
+ children = self._find_children(artefact_name, old_classifier)
127
+ children_count = self._count_children(children)
128
+ target_can_have_children = Classifier.can_have_children(new_classifier)
129
+ target_exists = self._check_target_exists(artefact_name, new_classifier)
130
+ requires_action = children_count > 0 and not target_can_have_children
131
+
132
+ return {
133
+ "has_children": children_count > 0,
134
+ "children_count": children_count,
135
+ "children": self._format_children_for_display(children),
136
+ "target_can_have_children": target_can_have_children,
137
+ "requires_action": requires_action,
138
+ "valid_new_parent_classifiers": self._get_valid_parents_for_children(
139
+ children
140
+ ),
141
+ "target_exists": target_exists,
142
+ "message": self._build_info_message(
143
+ target_exists,
144
+ requires_action,
145
+ children_count,
146
+ new_classifier,
147
+ artefact_name,
148
+ ),
149
+ }
150
+
151
+ def _format_children_for_display(self, children: Dict) -> Dict:
152
+ children_formatted = {}
153
+ for classifier, artefacts in children.items():
154
+ children_formatted[classifier] = [
155
+ {
156
+ "name": getattr(a, "title", "Unknown"),
157
+ "title": getattr(a, "title", "Unknown"),
158
+ }
159
+ for a in artefacts
160
+ ]
161
+ return children_formatted
162
+
163
+ def _get_valid_parents_for_children(self, children: Dict) -> List[str]:
164
+ child_classifiers = [c for c in children.keys() if children[c]]
165
+ return (
166
+ self._get_common_valid_parents(child_classifiers)
167
+ if child_classifiers
168
+ else []
169
+ )
170
+
171
+ def _build_info_message(
172
+ self,
173
+ target_exists: bool,
174
+ requires_action: bool,
175
+ children_count: int,
176
+ new_classifier: str,
177
+ artefact_name: str,
178
+ ) -> str:
179
+ messages = []
180
+ if target_exists:
181
+ messages.append(
182
+ f"Target {new_classifier} '{artefact_name}' already exists. Choose 'Override' or 'Merge'."
183
+ )
184
+ if requires_action:
185
+ messages.append(
186
+ f"Converting to '{new_classifier}' requires handling {children_count} children artefacts."
187
+ )
188
+ elif children_count > 0:
189
+ messages.append(
190
+ f"Found {children_count} children. They will be updated to reference the new classifier."
191
+ )
192
+ return " ".join(messages) if messages else ""
193
+
194
+ def _check_target_exists(self, artefact_name: str, classifier: str) -> bool:
195
+ """Check if target artefact already exists."""
196
+ from ara_cli.artefact_reader import ArtefactReader
197
+
198
+ reader = ArtefactReader(file_system=self.file_system)
199
+ _, artefact_info = reader.read_artefact_data(artefact_name, classifier)
200
+ return artefact_info is not None
201
+
202
+ def get_available_parents_for_reassign(self, child_classifiers: List[str]) -> Dict:
203
+ """
204
+ Get available artefacts that can be assigned as new parents.
205
+ Used by API to populate reassign dropdown.
206
+
207
+ Returns:
208
+ Dict with available parents:
209
+ {
210
+ "valid_classifiers": [str],
211
+ "available_artefacts": {classifier: [{name, title}]}
212
+ }
213
+ """
214
+ valid_classifiers = self._get_common_valid_parents(child_classifiers)
215
+ available = self._get_available_parents(valid_classifiers)
216
+
217
+ # Format for response
218
+ available_formatted = {}
219
+ for classifier, artefacts in available.items():
220
+ available_formatted[classifier] = [
221
+ {"name": a.get("title", "Unknown"), "title": a.get("title", "Unknown")}
222
+ for a in artefacts
223
+ ]
224
+
225
+ return {
226
+ "valid_classifiers": valid_classifiers,
227
+ "available_artefacts": available_formatted,
228
+ }
229
+
230
+ def update_children_contributions(
231
+ self,
232
+ artefact_name: str,
233
+ old_classifier: str,
234
+ new_classifier: str,
235
+ force: bool = False,
236
+ children_action: Optional[str] = None,
237
+ new_parent_classifier: Optional[str] = None,
238
+ new_parent_name: Optional[str] = None,
239
+ json_output: bool = False,
240
+ ) -> bool:
241
+ """
242
+ Update all children's contribution field after parent conversion.
243
+
244
+ Args:
245
+ artefact_name: Name of the parent artefact being converted
246
+ old_classifier: Original classifier of the parent
247
+ new_classifier: New classifier of the parent
248
+ force: If True, automatically clear contributions for leaf-node conversions
249
+ children_action: Non-interactive mode action (cancel/clear/reassign)
250
+ new_parent_classifier: For reassign action, the new parent's classifier
251
+ new_parent_name: For reassign action, the new parent's name
252
+ json_output: If True, output JSON instead of text messages
253
+
254
+ Returns:
255
+ bool: True if conversion should continue, False if cancelled
256
+ """
257
+ # Find all children contributing to this parent
258
+ children = self._find_children(artefact_name, old_classifier)
259
+
260
+ if not children:
261
+ return True # No children, continue conversion
262
+
263
+ children_count = self._count_children(children)
264
+ if not json_output:
265
+ print(
266
+ f"\n📦 Found {children_count} children contributing to '{artefact_name}' {old_classifier}."
267
+ )
268
+
269
+ # Check if new classifier can have children
270
+ if not Classifier.can_have_children(new_classifier):
271
+ return self._handle_leaf_node_conversion(
272
+ artefact_name,
273
+ old_classifier,
274
+ new_classifier,
275
+ children,
276
+ force,
277
+ children_action,
278
+ new_parent_classifier,
279
+ new_parent_name,
280
+ json_output,
281
+ )
282
+
283
+ # Normal case: update children's contribution to new classifier
284
+ return self._update_children_to_new_classifier(
285
+ artefact_name, old_classifier, new_classifier, children
286
+ )
287
+
288
+ def _find_children(self, artefact_name: str, classifier: str) -> Dict[str, List]:
289
+ """Find all children artefacts contributing to the parent."""
290
+ return ArtefactReader(self.file_system).find_children(artefact_name, classifier)
291
+
292
+ def _count_children(self, children: Dict[str, List]) -> int:
293
+ """Count total number of children across all classifiers."""
294
+ return sum(len(artefacts) for artefacts in children.values())
295
+
296
+ def _handle_leaf_node_conversion(
297
+ self,
298
+ artefact_name: str,
299
+ old_classifier: str,
300
+ new_classifier: str,
301
+ children: Dict[str, List],
302
+ force: bool,
303
+ children_action: Optional[str] = None,
304
+ new_parent_classifier: Optional[str] = None,
305
+ new_parent_name: Optional[str] = None,
306
+ json_output: bool = False,
307
+ ) -> bool:
308
+ """
309
+ Handle conversion to task/issue when parent has children.
310
+
311
+ Since task and issue cannot have children, user must choose:
312
+ 1. Cancel the operation
313
+ 2. Clear children's contribution field (--force default)
314
+ 3. Select a new parent for children
315
+
316
+ Non-interactive mode: Use children_action parameter directly.
317
+ """
318
+ if not json_output:
319
+ print(f"\n⚠️ Warning: '{new_classifier}' artefacts cannot have children.")
320
+
321
+ # Non-interactive mode (API)
322
+ if children_action:
323
+ return self._handle_non_interactive_action(
324
+ children,
325
+ children_action,
326
+ new_parent_classifier,
327
+ new_parent_name,
328
+ json_output,
329
+ )
330
+
331
+ if force:
332
+ if not json_output:
333
+ print("🔄 Force mode: Clearing children's contribution fields...")
334
+ self._clear_children_contributions(children, json_output)
335
+ return True
336
+
337
+ # Interactive mode - Present options to user
338
+ print("\nPlease choose an option:")
339
+ print(f" [{self.CHOICE_CANCEL}] Cancel the conversion")
340
+ print(
341
+ f" [{self.CHOICE_CLEAR_CONTRIBUTION}] Clear children's contribution field"
342
+ )
343
+ print(f" [{self.CHOICE_SELECT_NEW_PARENT}] Select a new parent for children")
344
+
345
+ choice = input("\nEnter your choice (1/2/3): ").strip()
346
+
347
+ if choice == self.CHOICE_CANCEL:
348
+ print("\n❌ Conversion cancelled. No changes were made.")
349
+ return False
350
+
351
+ elif choice == self.CHOICE_CLEAR_CONTRIBUTION:
352
+ self._clear_children_contributions(children, json_output)
353
+ return True
354
+
355
+ elif choice == self.CHOICE_SELECT_NEW_PARENT:
356
+ return self._select_new_parent_for_children(children, old_classifier)
357
+
358
+ else:
359
+ print("\n❌ Invalid choice. Conversion cancelled.")
360
+ return False
361
+
362
+ def _handle_non_interactive_action(
363
+ self,
364
+ children: Dict[str, List],
365
+ children_action: str,
366
+ new_parent_classifier: Optional[str],
367
+ new_parent_name: Optional[str],
368
+ json_output: bool = False,
369
+ ) -> bool:
370
+ """Handle children action in non-interactive (API) mode."""
371
+ action = children_action.lower()
372
+
373
+ action_handlers = {
374
+ ChildrenAction.CANCEL.value: lambda: self._action_cancel(json_output),
375
+ ChildrenAction.CLEAR.value: lambda: self._action_clear(
376
+ children, json_output
377
+ ),
378
+ ChildrenAction.REASSIGN.value: lambda: self._action_reassign(
379
+ children, new_parent_classifier, new_parent_name, json_output
380
+ ),
381
+ }
382
+
383
+ handler = action_handlers.get(action)
384
+ if handler:
385
+ return handler()
386
+
387
+ self._output_message(
388
+ json_output, "error", f"Invalid children_action: {children_action}"
389
+ )
390
+ return False
391
+
392
+ def _action_cancel(self, json_output: bool) -> bool:
393
+ """Handle cancel action."""
394
+ self._output_message(
395
+ json_output,
396
+ "cancelled",
397
+ "Conversion cancelled by user choice.",
398
+ text_msg="❌ Conversion cancelled by children_action parameter.",
399
+ )
400
+ return False
401
+
402
+ def _action_clear(self, children: Dict[str, List], json_output: bool) -> bool:
403
+ """Handle clear action."""
404
+ if not json_output:
405
+ print("🔄 Clearing children's contribution fields...")
406
+ self._clear_children_contributions(children, json_output)
407
+ return True
408
+
409
+ def _action_reassign(
410
+ self,
411
+ children: Dict[str, List],
412
+ new_parent_classifier: Optional[str],
413
+ new_parent_name: Optional[str],
414
+ json_output: bool,
415
+ ) -> bool:
416
+ """Handle reassign action."""
417
+ if not new_parent_classifier or not new_parent_name:
418
+ self._output_message(
419
+ json_output,
420
+ "error",
421
+ "new_parent_classifier and new_parent_name required for reassign action.",
422
+ text_msg="❌ Error: new_parent_classifier and new_parent_name are required for reassign action.",
423
+ )
424
+ return False
425
+
426
+ if not self._validate_parent_exists(new_parent_name, new_parent_classifier):
427
+ self._output_message(
428
+ json_output,
429
+ "error",
430
+ f"Artefact '{new_parent_name}' of type '{new_parent_classifier}' does not exist.",
431
+ )
432
+ return False
433
+
434
+ self._reassign_children_to_parent(
435
+ children, new_parent_name, new_parent_classifier, json_output
436
+ )
437
+ return True
438
+
439
+ def _output_message(
440
+ self,
441
+ json_output: bool,
442
+ status: str,
443
+ message: str,
444
+ text_msg: Optional[str] = None,
445
+ ) -> None:
446
+ """Output message in JSON or text format."""
447
+ if json_output:
448
+ print(json.dumps({"status": status, "message": message}))
449
+ else:
450
+ print(f"\n{text_msg or f'❌ {message}'}")
451
+
452
+ def _clear_children_contributions(
453
+ self, children: Dict[str, List], json_output: bool = False
454
+ ) -> None:
455
+ """Clear contribution field for all children artefacts."""
456
+ if not json_output:
457
+ print("\n🗑️ Clearing contributions...")
458
+
459
+ # Save current directory and navigate to target
460
+ original_dir = self.file_system.getcwd()
461
+ try:
462
+ navigator = DirectoryNavigator()
463
+ navigator.navigate_to_target()
464
+
465
+ for classifier, artefacts in children.items():
466
+ for artefact in artefacts:
467
+ artefact_title = getattr(artefact, "title", "Unknown")
468
+ artefact.contribution = None
469
+ self._save_artefact(artefact, classifier)
470
+ if not json_output:
471
+ print(
472
+ f" ✓ Cleared contribution for '{artefact_title}' {classifier}"
473
+ )
474
+
475
+ if not json_output:
476
+ print(f"\n✅ Cleared contribution for all children.")
477
+ finally:
478
+ # Restore original directory
479
+ self.file_system.chdir(original_dir)
480
+
481
+ def _select_new_parent_for_children(
482
+ self, children: Dict[str, List], old_classifier: str
483
+ ) -> bool:
484
+ """
485
+ Allow user to select a new parent for all children.
486
+ Prompts for classifier and artefact name instead of listing all options.
487
+ """
488
+ child_classifiers = [c for c in children.keys() if children[c]]
489
+ if not child_classifiers:
490
+ return True
491
+
492
+ valid_parents = self._get_common_valid_parents(child_classifiers)
493
+ if not valid_parents:
494
+ print(
495
+ "\n❌ No valid parent types available for children. Conversion cancelled."
496
+ )
497
+ return False
498
+
499
+ try:
500
+ classifier_input = self._prompt_for_classifier(valid_parents)
501
+ if not classifier_input:
502
+ return False
503
+
504
+ artefact_name = self._prompt_for_artefact_name(classifier_input)
505
+ if not artefact_name:
506
+ return False
507
+
508
+ self._reassign_children_to_parent(children, artefact_name, classifier_input)
509
+ return True
510
+ except (ValueError, EOFError, KeyboardInterrupt):
511
+ print("\n❌ Input cancelled. Conversion cancelled.")
512
+ return False
513
+
514
+ def _prompt_for_classifier(self, valid_parents: List[str]) -> Optional[str]:
515
+ """Prompt user to select a valid classifier."""
516
+ print(f"\nValid parent types for children: {', '.join(valid_parents)}")
517
+ print("(Use Tab for autocomplete)")
518
+
519
+ _setup_completer(valid_parents)
520
+ classifier_input = input("\nEnter parent classifier: ").strip().lower()
521
+ _clear_completer()
522
+
523
+ if not classifier_input:
524
+ print("\n❌ No classifier provided. Conversion cancelled.")
525
+ return None
526
+
527
+ if classifier_input not in valid_parents:
528
+ print(
529
+ f"\n❌ Invalid classifier '{classifier_input}'. Must be one of: {', '.join(valid_parents)}"
530
+ )
531
+ print("Conversion cancelled.")
532
+ return None
533
+
534
+ return classifier_input
535
+
536
+ def _prompt_for_artefact_name(self, classifier: str) -> Optional[str]:
537
+ """Prompt user to enter an artefact name and validate it exists."""
538
+ available_parents = self._get_available_parents([classifier])
539
+ artefact_options = [
540
+ a.get("title", "")
541
+ for a in available_parents.get(classifier, [])
542
+ if a.get("title")
543
+ ]
544
+
545
+ _setup_completer(artefact_options)
546
+ artefact_name = input(f"Enter {classifier} artefact name: ").strip()
547
+ _clear_completer()
548
+
549
+ if not artefact_name:
550
+ print("\n❌ No artefact name provided. Conversion cancelled.")
551
+ return None
552
+
553
+ if not self._validate_parent_exists(artefact_name, classifier):
554
+ print(
555
+ f"\n❌ Artefact '{artefact_name}' of type '{classifier}' does not exist."
556
+ )
557
+ print("Conversion cancelled.")
558
+ return None
559
+
560
+ return artefact_name
561
+
562
+ def _validate_parent_exists(self, artefact_name: str, classifier: str) -> bool:
563
+ """Validate that the specified parent artefact exists by checking if file exists."""
564
+ import os.path as ospath
565
+
566
+ sub_directory = Classifier.get_sub_directory(classifier)
567
+
568
+ # Check if file exists in ara directory (relative to current working directory)
569
+ file_path = ospath.join("ara", sub_directory, f"{artefact_name}.{classifier}")
570
+ return ospath.exists(file_path)
571
+
572
+ def _get_common_valid_parents(self, child_classifiers: List[str]) -> List[str]:
573
+ """Get parent classifiers that are valid for all children."""
574
+ if not child_classifiers:
575
+ return []
576
+
577
+ # Start with valid parents of first child
578
+ common_parents = set(
579
+ Classifier.get_valid_parent_classifiers(child_classifiers[0])
580
+ )
581
+
582
+ # Intersect with valid parents of other children
583
+ for classifier in child_classifiers[1:]:
584
+ valid_parents = set(Classifier.get_valid_parent_classifiers(classifier))
585
+ common_parents = common_parents.intersection(valid_parents)
586
+
587
+ # Filter out leaf-node classifiers (task, issue)
588
+ return [p for p in common_parents if Classifier.can_have_children(p)]
589
+
590
+ def _get_available_parents(self, valid_classifiers: List[str]) -> Dict[str, List]:
591
+ """Get available artefacts that can be assigned as parents."""
592
+ # Save current directory and navigate to target
593
+ original_dir = self.file_system.getcwd()
594
+ try:
595
+ navigator = DirectoryNavigator()
596
+ navigator.navigate_to_target()
597
+
598
+ file_classifier = FileClassifier(self.file_system)
599
+ all_classified_files = file_classifier.classify_files()
600
+
601
+ # Filter to only valid parent classifiers
602
+ classified_files = {
603
+ k: v for k, v in all_classified_files.items() if k in valid_classifiers
604
+ }
605
+
606
+ # Filter out empty classifiers
607
+ return {k: v for k, v in classified_files.items() if v}
608
+ finally:
609
+ # Restore original directory
610
+ self.file_system.chdir(original_dir)
611
+
612
+ def _reassign_children_to_parent(
613
+ self,
614
+ children: Dict[str, List],
615
+ new_parent_name: str,
616
+ new_parent_classifier: str,
617
+ json_output: bool = False,
618
+ ) -> None:
619
+ """Reassign all children to a new parent artefact."""
620
+ if not json_output:
621
+ print(
622
+ f"\n🔄 Reassigning children to '{new_parent_name}' {new_parent_classifier}..."
623
+ )
624
+
625
+ original_dir = self.file_system.getcwd()
626
+ try:
627
+ DirectoryNavigator().navigate_to_target()
628
+ supports_rules = new_parent_classifier in ["epic", "userstory"]
629
+
630
+ for classifier, artefacts in children.items():
631
+ for artefact in artefacts:
632
+ self._reassign_single_child(
633
+ artefact,
634
+ classifier,
635
+ new_parent_name,
636
+ new_parent_classifier,
637
+ supports_rules,
638
+ json_output,
639
+ )
640
+
641
+ if not json_output:
642
+ print("\n✅ All children reassigned successfully.")
643
+ finally:
644
+ self.file_system.chdir(original_dir)
645
+
646
+ def _reassign_single_child(
647
+ self,
648
+ artefact,
649
+ classifier: str,
650
+ new_parent_name: str,
651
+ new_parent_classifier: str,
652
+ supports_rules: bool,
653
+ json_output: bool,
654
+ ) -> None:
655
+ """Reassign a single child to new parent."""
656
+ artefact_title = getattr(artefact, "title", "Unknown")
657
+ current_rule = self._get_artefact_rule(artefact)
658
+ rule_to_set = current_rule if supports_rules else None
659
+
660
+ artefact.set_contribution(new_parent_name, new_parent_classifier, rule_to_set)
661
+ self._save_artefact(artefact, classifier)
662
+
663
+ if not json_output:
664
+ print(
665
+ f" ✓ '{artefact_title}' {classifier} → '{new_parent_name}' {new_parent_classifier}"
666
+ )
667
+
668
+ def _get_artefact_rule(self, artefact) -> Optional[str]:
669
+ """Extract rule from artefact contribution if exists."""
670
+ if hasattr(artefact, "contribution") and artefact.contribution:
671
+ return getattr(artefact.contribution, "rule", None)
672
+ return None
673
+
674
+ def _update_children_to_new_classifier(
675
+ self,
676
+ artefact_name: str,
677
+ old_classifier: str,
678
+ new_classifier: str,
679
+ children: Dict[str, List],
680
+ ) -> bool:
681
+ """Update children's contribution to point to new classifier."""
682
+ print(
683
+ f"\n🔄 Updating children's contribution to new classifier '{new_classifier}'..."
684
+ )
685
+
686
+ # Save current directory and navigate to target
687
+ original_dir = self.file_system.getcwd()
688
+ try:
689
+ navigator = DirectoryNavigator()
690
+ navigator.navigate_to_target()
691
+
692
+ # Determine if new parent supports rules
693
+ supports_rules = new_classifier in ["epic", "userstory"]
694
+
695
+ for classifier, artefacts in children.items():
696
+ for artefact in artefacts:
697
+ artefact_title = getattr(artefact, "title", "Unknown")
698
+
699
+ # Get current rule if exists
700
+ current_rule = None
701
+ if hasattr(artefact, "contribution") and artefact.contribution:
702
+ current_rule = getattr(artefact.contribution, "rule", None)
703
+
704
+ # Clear rule if new parent doesn't support rules
705
+ rule_to_set = current_rule if supports_rules else None
706
+
707
+ if not supports_rules and current_rule:
708
+ print(
709
+ f" ⚠️ Clearing rule '{current_rule}' for '{artefact_title}' (new parent doesn't support rules)"
710
+ )
711
+
712
+ artefact.set_contribution(
713
+ artefact_name, new_classifier, rule_to_set
714
+ )
715
+ self._save_artefact(artefact, classifier)
716
+ print(
717
+ f" ✓ '{artefact_title}' {classifier} contribution updated to '{new_classifier}'"
718
+ )
719
+
720
+ print(f"\n✅ All children's contributions updated successfully.")
721
+ return True
722
+ finally:
723
+ # Restore original directory
724
+ self.file_system.chdir(original_dir)
725
+
726
+ def _save_artefact(self, artefact, classifier: str) -> None:
727
+ """Save artefact to file. Assumes navigate_to_target() was already called."""
728
+ sub_directory = Classifier.get_sub_directory(classifier)
729
+ artefact_title = getattr(artefact, "title", "Unknown")
730
+ file_path = self.file_system.path.join(
731
+ sub_directory, f"{artefact_title}.{classifier}"
732
+ )
733
+
734
+ content = artefact.serialize()
735
+
736
+ with open(file_path, "w", encoding="utf-8") as f:
737
+ f.write(content)