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.
- ara_cli/__init__.py +51 -6
- ara_cli/__main__.py +87 -75
- ara_cli/ara_command_action.py +189 -101
- ara_cli/ara_config.py +187 -128
- ara_cli/ara_subcommands/common.py +2 -2
- ara_cli/ara_subcommands/config.py +221 -0
- ara_cli/ara_subcommands/convert.py +107 -0
- ara_cli/ara_subcommands/fetch.py +41 -0
- ara_cli/ara_subcommands/fetch_agents.py +22 -0
- ara_cli/ara_subcommands/fetch_scripts.py +19 -0
- ara_cli/ara_subcommands/fetch_templates.py +15 -10
- ara_cli/ara_subcommands/list.py +97 -23
- ara_cli/ara_subcommands/prompt.py +266 -106
- ara_cli/artefact_autofix.py +117 -64
- ara_cli/artefact_converter.py +355 -0
- ara_cli/artefact_creator.py +41 -17
- ara_cli/artefact_lister.py +3 -3
- ara_cli/artefact_models/artefact_model.py +1 -1
- ara_cli/artefact_models/artefact_templates.py +0 -9
- ara_cli/artefact_models/feature_artefact_model.py +8 -8
- ara_cli/artefact_reader.py +62 -43
- ara_cli/artefact_scan.py +39 -17
- ara_cli/chat.py +300 -71
- ara_cli/chat_agent/__init__.py +0 -0
- ara_cli/chat_agent/agent_process_manager.py +155 -0
- ara_cli/chat_script_runner/__init__.py +0 -0
- ara_cli/chat_script_runner/script_completer.py +23 -0
- ara_cli/chat_script_runner/script_finder.py +41 -0
- ara_cli/chat_script_runner/script_lister.py +36 -0
- ara_cli/chat_script_runner/script_runner.py +36 -0
- ara_cli/chat_web_search/__init__.py +0 -0
- ara_cli/chat_web_search/web_search.py +263 -0
- ara_cli/children_contribution_updater.py +737 -0
- ara_cli/classifier.py +34 -0
- ara_cli/commands/agent_run_command.py +98 -0
- ara_cli/commands/fetch_agents_command.py +106 -0
- ara_cli/commands/fetch_scripts_command.py +43 -0
- ara_cli/commands/fetch_templates_command.py +39 -0
- ara_cli/commands/fetch_templates_commands.py +39 -0
- ara_cli/commands/list_agents_command.py +39 -0
- ara_cli/commands/load_command.py +4 -3
- ara_cli/commands/load_image_command.py +1 -1
- ara_cli/commands/read_command.py +23 -27
- ara_cli/completers.py +95 -35
- ara_cli/constants.py +2 -0
- ara_cli/directory_navigator.py +37 -4
- ara_cli/error_handler.py +26 -11
- ara_cli/file_loaders/document_reader.py +0 -178
- ara_cli/file_loaders/factories/__init__.py +0 -0
- ara_cli/file_loaders/factories/document_reader_factory.py +32 -0
- ara_cli/file_loaders/factories/file_loader_factory.py +27 -0
- ara_cli/file_loaders/file_loader.py +1 -30
- ara_cli/file_loaders/loaders/__init__.py +0 -0
- ara_cli/file_loaders/{document_file_loader.py → loaders/document_file_loader.py} +1 -1
- ara_cli/file_loaders/loaders/text_file_loader.py +47 -0
- ara_cli/file_loaders/readers/__init__.py +0 -0
- ara_cli/file_loaders/readers/docx_reader.py +49 -0
- ara_cli/file_loaders/readers/excel_reader.py +27 -0
- ara_cli/file_loaders/{markdown_reader.py → readers/markdown_reader.py} +1 -1
- ara_cli/file_loaders/readers/odt_reader.py +59 -0
- ara_cli/file_loaders/readers/pdf_reader.py +54 -0
- ara_cli/file_loaders/readers/pptx_reader.py +104 -0
- ara_cli/file_loaders/tools/__init__.py +0 -0
- ara_cli/llm_utils.py +58 -0
- ara_cli/output_suppressor.py +53 -0
- ara_cli/prompt_chat.py +20 -4
- ara_cli/prompt_extractor.py +47 -32
- ara_cli/prompt_handler.py +123 -17
- ara_cli/tag_extractor.py +8 -7
- ara_cli/template_loader.py +2 -1
- ara_cli/template_manager.py +52 -21
- ara_cli/templates/global-scripts/hello_global.py +1 -0
- ara_cli/templates/prompt-modules/commands/add_scenarios_for_new_behaviour.feature_creation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/align_feature_with_implementation_changes.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/analyze_codebase_and_plan_tasks.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/choose_best_parent_artefact.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/create_tasks_from_artefact_content.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/create_tests_for_uncovered_modules.test_generation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/derive_features_from_video_description.feature_creation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/describe_agent_capabilities.agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
- ara_cli/templates/prompt-modules/commands/execute_scoped_todos_in_task.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/explain_single_file_purpose.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/extract_file_information_bullets.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
- ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
- ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
- ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
- ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
- ara_cli/templates/prompt-modules/commands/fix_failing_behave_step_definitions.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/fix_failing_pytest_tests.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/general_instruction_policy.commands.md +47 -0
- ara_cli/templates/prompt-modules/commands/generate_and_fix_pytest_tests.test_generation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
- ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
- ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
- ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
- ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
- ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
- ara_cli/templates/prompt-modules/commands/suggest_next_story_child_tasks.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/summarize_or_transcribe_media.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/update_feature_to_match_implementation.feature_creation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/update_user_story_with_requirements.interview_agent.commands.md +1 -0
- ara_cli/version.py +1 -1
- {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/METADATA +49 -11
- ara_cli-0.1.14.0.dist-info/RECORD +253 -0
- {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/WHEEL +1 -1
- tests/test_ara_command_action.py +31 -19
- tests/test_ara_config.py +177 -90
- tests/test_artefact_autofix.py +170 -97
- tests/test_artefact_autofix_integration.py +495 -0
- tests/test_artefact_converter.py +312 -0
- tests/test_artefact_extraction.py +564 -0
- tests/test_artefact_lister.py +11 -8
- tests/test_chat.py +166 -130
- tests/test_chat_givens_images.py +603 -0
- tests/test_chat_script_runner.py +454 -0
- tests/test_children_contribution_updater.py +98 -0
- tests/test_document_loader_office.py +267 -0
- tests/test_llm_utils.py +164 -0
- tests/test_prompt_chat.py +343 -0
- tests/test_prompt_extractor.py +683 -0
- tests/test_prompt_handler.py +416 -214
- tests/test_setup_default_chat_prompt_mode.py +198 -0
- tests/test_tag_extractor.py +95 -49
- tests/test_web_search.py +467 -0
- ara_cli/file_loaders/document_readers.py +0 -233
- ara_cli/file_loaders/file_loaders.py +0 -123
- ara_cli/file_loaders/text_file_loader.py +0 -187
- ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
- ara_cli/templates/prompt-modules/blueprints/pytest_unittest_prompt.blueprint.md +0 -32
- ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
- ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
- ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
- ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
- ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
- ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
- ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
- ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
- ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
- ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
- ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
- ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
- ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
- ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
- ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
- ara_cli-0.1.10.5.dist-info/RECORD +0 -194
- /ara_cli/file_loaders/{binary_file_loader.py → loaders/binary_file_loader.py} +0 -0
- /ara_cli/file_loaders/{image_processor.py → tools/image_processor.py} +0 -0
- {ara_cli-0.1.10.5.dist-info → ara_cli-0.1.14.0.dist-info}/entry_points.txt +0 -0
- {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)
|