ara-cli 0.1.10.0__py3-none-any.whl → 0.1.13.3__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 +270 -103
- ara_cli/ara_command_action.py +106 -63
- ara_cli/ara_config.py +187 -128
- ara_cli/ara_subcommands/__init__.py +0 -0
- ara_cli/ara_subcommands/autofix.py +26 -0
- ara_cli/ara_subcommands/chat.py +27 -0
- ara_cli/ara_subcommands/classifier_directory.py +16 -0
- ara_cli/ara_subcommands/common.py +100 -0
- ara_cli/ara_subcommands/config.py +221 -0
- ara_cli/ara_subcommands/convert.py +43 -0
- ara_cli/ara_subcommands/create.py +75 -0
- ara_cli/ara_subcommands/delete.py +22 -0
- ara_cli/ara_subcommands/extract.py +22 -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 +19 -0
- ara_cli/ara_subcommands/list.py +139 -0
- ara_cli/ara_subcommands/list_tags.py +25 -0
- ara_cli/ara_subcommands/load.py +48 -0
- ara_cli/ara_subcommands/prompt.py +136 -0
- ara_cli/ara_subcommands/read.py +47 -0
- ara_cli/ara_subcommands/read_status.py +20 -0
- ara_cli/ara_subcommands/read_user.py +20 -0
- ara_cli/ara_subcommands/reconnect.py +27 -0
- ara_cli/ara_subcommands/rename.py +22 -0
- ara_cli/ara_subcommands/scan.py +14 -0
- ara_cli/ara_subcommands/set_status.py +22 -0
- ara_cli/ara_subcommands/set_user.py +22 -0
- ara_cli/ara_subcommands/template.py +16 -0
- ara_cli/artefact_autofix.py +154 -63
- ara_cli/artefact_converter.py +256 -0
- ara_cli/artefact_models/artefact_model.py +106 -25
- ara_cli/artefact_models/artefact_templates.py +20 -10
- ara_cli/artefact_models/epic_artefact_model.py +11 -2
- ara_cli/artefact_models/feature_artefact_model.py +31 -1
- ara_cli/artefact_models/userstory_artefact_model.py +15 -3
- ara_cli/artefact_scan.py +2 -2
- ara_cli/chat.py +283 -80
- 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/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/read_command.py +17 -4
- ara_cli/completers.py +180 -0
- ara_cli/constants.py +2 -0
- ara_cli/directory_navigator.py +37 -4
- ara_cli/file_loaders/text_file_loader.py +2 -2
- ara_cli/global_file_lister.py +5 -15
- ara_cli/llm_utils.py +58 -0
- ara_cli/prompt_chat.py +20 -4
- ara_cli/prompt_extractor.py +199 -76
- ara_cli/prompt_handler.py +160 -59
- ara_cli/tag_extractor.py +38 -18
- ara_cli/template_loader.py +3 -2
- 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.0.dist-info → ara_cli-0.1.13.3.dist-info}/METADATA +34 -1
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/RECORD +123 -54
- 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 +357 -0
- tests/test_artefact_extraction.py +564 -0
- tests/test_artefact_scan.py +1 -1
- tests/test_chat.py +162 -126
- tests/test_chat_givens_images.py +603 -0
- tests/test_chat_script_runner.py +454 -0
- tests/test_global_file_lister.py +1 -1
- 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 +12 -4
- tests/test_tag_extractor.py +19 -13
- tests/test_web_search.py +467 -0
- ara_cli/ara_command_parser.py +0 -605
- ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
- 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.0.dist-info → ara_cli-0.1.13.3.dist-info}/WHEEL +0 -0
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/entry_points.txt +0 -0
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from ara_cli.prompt_handler import LLMSingleton
|
|
4
|
+
from langfuse.api.resources.commons.errors import Error as LangfuseError, NotFoundError
|
|
5
|
+
from ara_cli.classifier import Classifier
|
|
6
|
+
from ara_cli.artefact_reader import ArtefactReader
|
|
7
|
+
from ara_cli.artefact_creator import ArtefactCreator
|
|
8
|
+
from ara_cli.error_handler import AraError
|
|
9
|
+
from ara_cli.directory_navigator import DirectoryNavigator
|
|
10
|
+
from ara_cli.artefact_deleter import ArtefactDeleter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AraArtefactConverter:
|
|
14
|
+
def __init__(self, file_system=None):
|
|
15
|
+
self.file_system = file_system or os
|
|
16
|
+
self.reader = ArtefactReader()
|
|
17
|
+
self.creator = ArtefactCreator(self.file_system)
|
|
18
|
+
|
|
19
|
+
def convert(
|
|
20
|
+
self,
|
|
21
|
+
old_classifier: str,
|
|
22
|
+
artefact_name: str,
|
|
23
|
+
new_classifier: str,
|
|
24
|
+
merge: bool = False,
|
|
25
|
+
override: bool = False,
|
|
26
|
+
):
|
|
27
|
+
try:
|
|
28
|
+
self._validate_classifiers(old_classifier, new_classifier)
|
|
29
|
+
|
|
30
|
+
content, artefact_info = self.reader.read_artefact_data(
|
|
31
|
+
artefact_name, old_classifier
|
|
32
|
+
)
|
|
33
|
+
if not content or not artefact_info:
|
|
34
|
+
raise AraError(
|
|
35
|
+
f"Artefact '{artefact_name}' of type '{old_classifier}' not found"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
target_content_existing = self._resolve_target_content(
|
|
39
|
+
artefact_name, new_classifier, merge, override
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
target_class = self._get_target_class(new_classifier)
|
|
43
|
+
|
|
44
|
+
prompt = self._get_prompt(
|
|
45
|
+
old_classifier=old_classifier,
|
|
46
|
+
new_classifier=new_classifier,
|
|
47
|
+
artefact_name=artefact_name,
|
|
48
|
+
content=content,
|
|
49
|
+
target_content_existing=target_content_existing,
|
|
50
|
+
merge=merge,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
print(
|
|
54
|
+
f"{'Merging' if merge and target_content_existing else 'Converting'} '{artefact_name}' from {old_classifier} to {new_classifier}..."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
converted_artefact = self._run_conversion_agent(prompt, target_class)
|
|
58
|
+
artefact_content = converted_artefact.serialize()
|
|
59
|
+
|
|
60
|
+
self._write_artefact(
|
|
61
|
+
new_classifier,
|
|
62
|
+
artefact_name,
|
|
63
|
+
artefact_content,
|
|
64
|
+
merge=merge,
|
|
65
|
+
override=override,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if old_classifier != new_classifier:
|
|
69
|
+
self._move_data_folder_content(
|
|
70
|
+
old_classifier, new_classifier, artefact_name
|
|
71
|
+
)
|
|
72
|
+
deleter = ArtefactDeleter(self.file_system)
|
|
73
|
+
deleter.delete(artefact_name, old_classifier, force=True)
|
|
74
|
+
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
raise e
|
|
77
|
+
except AraError as e:
|
|
78
|
+
raise e
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise e
|
|
81
|
+
|
|
82
|
+
def _validate_classifiers(self, old_classifier: str, new_classifier: str):
|
|
83
|
+
if not Classifier.is_valid_classifier(old_classifier):
|
|
84
|
+
raise ValueError(f"Invalid classifier: {old_classifier}")
|
|
85
|
+
if not Classifier.is_valid_classifier(new_classifier):
|
|
86
|
+
raise ValueError(f"Invalid classifier: {new_classifier}")
|
|
87
|
+
|
|
88
|
+
def _move_data_folder_content(self, old_classifier, new_classifier, artefact_name):
|
|
89
|
+
import shutil
|
|
90
|
+
|
|
91
|
+
navigator = DirectoryNavigator()
|
|
92
|
+
navigator.navigate_to_target()
|
|
93
|
+
|
|
94
|
+
sub_directory_old = Classifier.get_sub_directory(old_classifier)
|
|
95
|
+
dir_path_old = self.file_system.path.join(
|
|
96
|
+
sub_directory_old, f"{artefact_name}.data"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
sub_directory_new = Classifier.get_sub_directory(new_classifier)
|
|
100
|
+
dir_path_new = self.file_system.path.join(
|
|
101
|
+
sub_directory_new, f"{artefact_name}.data"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if self.file_system.path.exists(dir_path_old):
|
|
105
|
+
backup_folder_name = f"{artefact_name}.data.old"
|
|
106
|
+
destination_path = self.file_system.path.join(
|
|
107
|
+
dir_path_new, backup_folder_name
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not self.file_system.path.exists(dir_path_new):
|
|
111
|
+
os.makedirs(dir_path_new, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
if self.file_system.path.exists(destination_path):
|
|
114
|
+
shutil.rmtree(destination_path)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
shutil.move(dir_path_old, destination_path)
|
|
118
|
+
print(f"Moved old data to {destination_path}")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f"Error moving data directory: {e}")
|
|
121
|
+
|
|
122
|
+
def _resolve_target_content(
|
|
123
|
+
self, artefact_name: str, new_classifier: str, merge: bool, override: bool
|
|
124
|
+
):
|
|
125
|
+
target_content_existing = None
|
|
126
|
+
if not merge and not override:
|
|
127
|
+
_, new_artefact_info = self.reader.read_artefact_data(
|
|
128
|
+
artefact_name, new_classifier
|
|
129
|
+
)
|
|
130
|
+
if new_artefact_info:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Found already exiting {new_classifier} {artefact_name}. Rerun the command with --override or --merge."
|
|
133
|
+
)
|
|
134
|
+
elif merge:
|
|
135
|
+
target_content_existing, _ = self.reader.read_artefact_data(
|
|
136
|
+
artefact_name, new_classifier
|
|
137
|
+
)
|
|
138
|
+
return target_content_existing
|
|
139
|
+
|
|
140
|
+
def _get_target_class(self, new_classifier: str):
|
|
141
|
+
from ara_cli.artefact_models.artefact_mapping import artefact_type_mapping
|
|
142
|
+
from ara_cli.artefact_models.artefact_model import ArtefactType
|
|
143
|
+
|
|
144
|
+
target_type = ArtefactType(new_classifier)
|
|
145
|
+
target_class = artefact_type_mapping.get(target_type)
|
|
146
|
+
|
|
147
|
+
if not target_class:
|
|
148
|
+
raise AraError(f"No artefact class found for classifier: {new_classifier}")
|
|
149
|
+
return target_class
|
|
150
|
+
|
|
151
|
+
def _get_prompt(
|
|
152
|
+
self,
|
|
153
|
+
old_classifier,
|
|
154
|
+
new_classifier,
|
|
155
|
+
artefact_name,
|
|
156
|
+
content,
|
|
157
|
+
target_content_existing,
|
|
158
|
+
merge,
|
|
159
|
+
):
|
|
160
|
+
try:
|
|
161
|
+
langfuse = LLMSingleton.get_instance().langfuse
|
|
162
|
+
if langfuse is None:
|
|
163
|
+
# This mimics the behavior if authentication failed or env vars missing in Singleton
|
|
164
|
+
raise Exception("Langfuse not initialized in Singleton")
|
|
165
|
+
|
|
166
|
+
if merge and target_content_existing:
|
|
167
|
+
prompt_template = langfuse.get_prompt("ara-cli/artefact-convert/merge")
|
|
168
|
+
return prompt_template.compile(
|
|
169
|
+
old_classifier=old_classifier,
|
|
170
|
+
new_classifier=new_classifier,
|
|
171
|
+
artefact_name=artefact_name,
|
|
172
|
+
content=content,
|
|
173
|
+
target_content_existing=target_content_existing,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
prompt_template = langfuse.get_prompt(
|
|
177
|
+
"ara-cli/artefact-convert/default"
|
|
178
|
+
)
|
|
179
|
+
return prompt_template.compile(
|
|
180
|
+
old_classifier=old_classifier,
|
|
181
|
+
new_classifier=new_classifier,
|
|
182
|
+
artefact_name=artefact_name,
|
|
183
|
+
content=content,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
except (LangfuseError, NotFoundError, Exception) as e:
|
|
187
|
+
logging.info(f"Could not fetch Langfuse prompt: {e}. Using fallback.")
|
|
188
|
+
# Fallback prompts
|
|
189
|
+
formatting_instructions = (
|
|
190
|
+
"### Data Extraction Rules:\n"
|
|
191
|
+
"- **Users**: Extract usernames ONLY. Do NOT add '@' or 'user_' prefixes. "
|
|
192
|
+
"The system adds these automatically. (e.g., return 'hans', NOT '@user_hans').\n"
|
|
193
|
+
"- **Author**: Extract the author as 'creator_<name>'. Do NOT add the '@' symbol. "
|
|
194
|
+
"(e.g., return 'creator_unknown', NOT '@creator_unknown').\n"
|
|
195
|
+
f"- **Artefact Name**: Use strictly '{artefact_name}'.\n"
|
|
196
|
+
"- **Content**: Adapt the content to the target schema fields."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if merge and target_content_existing:
|
|
200
|
+
return (
|
|
201
|
+
f"Merge the following {old_classifier} artefact into the existing {new_classifier} artefact. "
|
|
202
|
+
"Combine the information from both, prioritizing the structure of the target artefact schema. "
|
|
203
|
+
"Ensure no critical information is lost. "
|
|
204
|
+
"\n\n"
|
|
205
|
+
f"{formatting_instructions}"
|
|
206
|
+
"\n\n"
|
|
207
|
+
f"Source Artefact ({old_classifier}):\n```\n{content}\n```"
|
|
208
|
+
f"\n\nTarget Artefact ({new_classifier}):\n```\n{target_content_existing}\n```"
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
return (
|
|
212
|
+
f"Convert the following {old_classifier} artefact to a {new_classifier} artefact. "
|
|
213
|
+
"Preserve the core meaning, business value, and description. "
|
|
214
|
+
"Map the content to the fields required by the target schema. "
|
|
215
|
+
"\n\n"
|
|
216
|
+
f"{formatting_instructions}"
|
|
217
|
+
"\n\n"
|
|
218
|
+
f"Source Artefact Content:\n```\n{content}\n```"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _run_conversion_agent(self, prompt, target_class):
|
|
222
|
+
from ara_cli.llm_utils import create_pydantic_ai_agent
|
|
223
|
+
|
|
224
|
+
agent = create_pydantic_ai_agent(output_type=target_class, instrument=True)
|
|
225
|
+
try:
|
|
226
|
+
result = agent.run_sync(prompt)
|
|
227
|
+
return result.output
|
|
228
|
+
except Exception as e:
|
|
229
|
+
raise AraError(f"LLM conversion failed: {e}")
|
|
230
|
+
|
|
231
|
+
def _write_artefact(
|
|
232
|
+
self, new_classifier, artefact_name, artefact_content, merge, override
|
|
233
|
+
):
|
|
234
|
+
from shutil import rmtree
|
|
235
|
+
|
|
236
|
+
navigator = DirectoryNavigator()
|
|
237
|
+
navigator.navigate_to_target()
|
|
238
|
+
|
|
239
|
+
sub_directory = Classifier.get_sub_directory(new_classifier)
|
|
240
|
+
file_path = self.file_system.path.join(
|
|
241
|
+
sub_directory, f"{artefact_name}.{new_classifier}"
|
|
242
|
+
)
|
|
243
|
+
dir_path = self.file_system.path.join(sub_directory, f"{artefact_name}.data")
|
|
244
|
+
|
|
245
|
+
if self.file_system.path.exists(file_path) and not (override or merge):
|
|
246
|
+
raise ValueError(f"Target file {file_path} already exists.")
|
|
247
|
+
|
|
248
|
+
if not merge:
|
|
249
|
+
rmtree(dir_path, ignore_errors=True)
|
|
250
|
+
|
|
251
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
252
|
+
|
|
253
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
254
|
+
f.write(artefact_content)
|
|
255
|
+
|
|
256
|
+
print(f"Conversion successful. Created: {file_path}")
|
|
@@ -184,6 +184,10 @@ class Artefact(BaseModel, ABC):
|
|
|
184
184
|
default=[],
|
|
185
185
|
description="Optional list of tags (0-many)",
|
|
186
186
|
)
|
|
187
|
+
author: Optional[str] = Field(
|
|
188
|
+
default="creator_unknown",
|
|
189
|
+
description="Author of the artefact, must be a single entry of the form 'creator_<someone>'."
|
|
190
|
+
)
|
|
187
191
|
title: str = Field(
|
|
188
192
|
...,
|
|
189
193
|
description="Descriptive Artefact title (mandatory)",
|
|
@@ -252,6 +256,17 @@ class Artefact(BaseModel, ABC):
|
|
|
252
256
|
raise ValueError(f"Tag '{tag}' has the form of a status tag. Set `status` field instead of passing it with other tags")
|
|
253
257
|
if tag.startswith("user_"):
|
|
254
258
|
raise ValueError(f"Tag '{tag} has the form of a user tag. Set `users` field instead of passing it with other tags")
|
|
259
|
+
if tag.startswith("creator_"):
|
|
260
|
+
raise ValueError(f"Tag '{tag}' has the form of an author tag. Set `author` field instead of passing it with other tags")
|
|
261
|
+
return v
|
|
262
|
+
|
|
263
|
+
@field_validator('author')
|
|
264
|
+
def validate_author(cls, v):
|
|
265
|
+
if v:
|
|
266
|
+
if not v.startswith("creator_"):
|
|
267
|
+
raise ValueError(f"Author '{v}' must start with 'creator_'.")
|
|
268
|
+
if len(v) <= len("creator_"):
|
|
269
|
+
raise ValueError("Creator name cannot be empty in author tag.")
|
|
255
270
|
return v
|
|
256
271
|
|
|
257
272
|
@field_validator('title')
|
|
@@ -291,32 +306,81 @@ class Artefact(BaseModel, ABC):
|
|
|
291
306
|
tag_line = lines[0]
|
|
292
307
|
if not tag_line.startswith('@'):
|
|
293
308
|
return {}, lines
|
|
309
|
+
|
|
294
310
|
tags = tag_line.split()
|
|
311
|
+
tag_dict = cls._process_tags(tags)
|
|
312
|
+
return tag_dict, lines[1:]
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def _process_tags(cls, tags) -> Dict[str, str]:
|
|
316
|
+
"""Process a list of tags and return a dictionary with categorized tags."""
|
|
295
317
|
status = None
|
|
296
318
|
regular_tags = []
|
|
297
319
|
users = []
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
user_prefix_length = len(user_prefix)
|
|
301
|
-
|
|
320
|
+
author = None
|
|
321
|
+
|
|
302
322
|
for tag in tags:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if tag
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
cls._validate_tag_format(tag)
|
|
324
|
+
|
|
325
|
+
if cls._is_status_tag(tag):
|
|
326
|
+
status = cls._process_status_tag(tag, status)
|
|
327
|
+
elif cls._is_user_tag(tag):
|
|
328
|
+
users.append(cls._extract_user_from_tag(tag))
|
|
329
|
+
elif cls._is_author_tag(tag):
|
|
330
|
+
author = cls._process_author_tag(tag, author)
|
|
331
|
+
else:
|
|
332
|
+
regular_tags.append(tag[1:])
|
|
333
|
+
|
|
334
|
+
return {
|
|
315
335
|
"status": status,
|
|
316
336
|
"users": users,
|
|
317
|
-
"tags": regular_tags
|
|
337
|
+
"tags": regular_tags,
|
|
338
|
+
"author": author
|
|
318
339
|
}
|
|
319
|
-
|
|
340
|
+
|
|
341
|
+
@classmethod
|
|
342
|
+
def _validate_tag_format(cls, tag):
|
|
343
|
+
"""Validate that tag starts with @."""
|
|
344
|
+
if not tag.startswith('@'):
|
|
345
|
+
raise ValueError(f"Tag '{tag}' should start with '@' but started with '{tag[0]}'")
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def _is_status_tag(cls, tag) -> bool:
|
|
349
|
+
"""Check if tag is a status tag."""
|
|
350
|
+
status_list = ["@to-do", "@in-progress", "@review", "@done", "@closed"]
|
|
351
|
+
return tag in status_list
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def _process_status_tag(cls, tag, current_status):
|
|
355
|
+
"""Process status tag and check for duplicates."""
|
|
356
|
+
if current_status is not None:
|
|
357
|
+
raise ValueError(f"Multiple status tags found: '@{current_status}' and '{tag}'")
|
|
358
|
+
return tag[1:] # Remove @ prefix
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def _is_user_tag(cls, tag) -> bool:
|
|
362
|
+
"""Check if tag is a user tag."""
|
|
363
|
+
user_prefix = "@user_"
|
|
364
|
+
return tag.startswith(user_prefix) and len(tag) > len(user_prefix)
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def _extract_user_from_tag(cls, tag) -> str:
|
|
368
|
+
"""Extract username from user tag."""
|
|
369
|
+
user_prefix = "@user_"
|
|
370
|
+
return tag[len(user_prefix):]
|
|
371
|
+
|
|
372
|
+
@classmethod
|
|
373
|
+
def _is_author_tag(cls, tag) -> bool:
|
|
374
|
+
"""Check if tag is an author tag."""
|
|
375
|
+
creator_prefix = "@creator_"
|
|
376
|
+
return tag.startswith(creator_prefix) and len(tag) > len(creator_prefix)
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def _process_author_tag(cls, tag, current_author):
|
|
380
|
+
"""Process author tag and check for duplicates."""
|
|
381
|
+
if current_author is not None:
|
|
382
|
+
raise ValueError(f"Multiple author tags found: '@{current_author}' and '@{tag[1:]}'")
|
|
383
|
+
return tag[1:]
|
|
320
384
|
|
|
321
385
|
@classmethod
|
|
322
386
|
def _deserialize_title(cls, lines) -> (str, List[str]):
|
|
@@ -346,14 +410,26 @@ class Artefact(BaseModel, ABC):
|
|
|
346
410
|
return contribution, lines
|
|
347
411
|
|
|
348
412
|
@classmethod
|
|
349
|
-
def _deserialize_description(cls, lines) -> (Optional[str], List[str]):
|
|
413
|
+
def _deserialize_description(cls, lines: List[str]) -> (Optional[str], List[str]):
|
|
350
414
|
description_start = cls._description_starts_with()
|
|
415
|
+
start_index = -1
|
|
351
416
|
for i, line in enumerate(lines):
|
|
352
417
|
if line.startswith(description_start):
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
418
|
+
start_index = i
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
if start_index == -1:
|
|
422
|
+
return None, lines
|
|
423
|
+
|
|
424
|
+
first_line_content = lines[start_index][len(description_start):].strip()
|
|
425
|
+
|
|
426
|
+
description_lines = ([first_line_content] if first_line_content else []) + lines[start_index + 1:]
|
|
427
|
+
|
|
428
|
+
description = "\n".join(description_lines)
|
|
429
|
+
|
|
430
|
+
remaining_lines = lines[:start_index]
|
|
431
|
+
|
|
432
|
+
return (description if description else None), remaining_lines
|
|
357
433
|
|
|
358
434
|
@classmethod
|
|
359
435
|
def _parse_common_fields(cls, text: str) -> dict:
|
|
@@ -367,7 +443,7 @@ class Artefact(BaseModel, ABC):
|
|
|
367
443
|
contribution, remaining_lines = cls._deserialize_contribution(remaining_lines)
|
|
368
444
|
description, remaining_lines = cls._deserialize_description(remaining_lines)
|
|
369
445
|
|
|
370
|
-
|
|
446
|
+
fields = {
|
|
371
447
|
'artefact_type': cls._artefact_type(),
|
|
372
448
|
'tags': tags.get('tags', []),
|
|
373
449
|
'users': tags.get('users', []),
|
|
@@ -376,6 +452,9 @@ class Artefact(BaseModel, ABC):
|
|
|
376
452
|
'contribution': contribution,
|
|
377
453
|
'description': description,
|
|
378
454
|
}
|
|
455
|
+
if tags.get("author"):
|
|
456
|
+
fields["author"] = tags.get("author")
|
|
457
|
+
return fields
|
|
379
458
|
|
|
380
459
|
@classmethod
|
|
381
460
|
def deserialize(cls, text: str) -> 'Artefact':
|
|
@@ -407,6 +486,8 @@ class Artefact(BaseModel, ABC):
|
|
|
407
486
|
tags.append(f"@{self.status}")
|
|
408
487
|
for user in self.users:
|
|
409
488
|
tags.append(f"@user_{user}")
|
|
489
|
+
if self.author:
|
|
490
|
+
tags.append(f"@{self.author}")
|
|
410
491
|
for tag in self.tags:
|
|
411
492
|
tags.append(f"@{tag}")
|
|
412
493
|
return ' '.join(tags)
|
|
@@ -430,4 +511,4 @@ class Artefact(BaseModel, ABC):
|
|
|
430
511
|
classifier=classifier,
|
|
431
512
|
rule=rule
|
|
432
513
|
)
|
|
433
|
-
self.contribution = contribution
|
|
514
|
+
self.contribution = contribution
|
|
@@ -29,7 +29,8 @@ def _default_vision(title: str, use_default_contribution: bool) -> VisionArtefac
|
|
|
29
29
|
our_product="<statement of primary differentiation>"
|
|
30
30
|
)
|
|
31
31
|
return VisionArtefact(
|
|
32
|
-
tags=[
|
|
32
|
+
tags=[],
|
|
33
|
+
author="creator_unknown",
|
|
33
34
|
title=title,
|
|
34
35
|
description="<further optional description to understand the vision, markdown capable text formatting>",
|
|
35
36
|
intent=intent,
|
|
@@ -44,7 +45,8 @@ def _default_businessgoal(title: str, use_default_contribution: bool) -> Busines
|
|
|
44
45
|
i_want="<something that helps me to reach my monetary goal>"
|
|
45
46
|
)
|
|
46
47
|
return BusinessgoalArtefact(
|
|
47
|
-
tags=[
|
|
48
|
+
tags=[],
|
|
49
|
+
author="creator_unknown",
|
|
48
50
|
title=title,
|
|
49
51
|
description="<further optional description to understand the businessgoal, markdown capable text formatting>",
|
|
50
52
|
intent=intent,
|
|
@@ -57,7 +59,8 @@ def _default_capability(title: str, use_default_contribution: bool) -> Capabilit
|
|
|
57
59
|
to_be_able_to="<needed capability for stakeholders that are the enablers/relevant for reaching the business goal>"
|
|
58
60
|
)
|
|
59
61
|
return CapabilityArtefact(
|
|
60
|
-
tags=[
|
|
62
|
+
tags=[],
|
|
63
|
+
author="creator_unknown",
|
|
61
64
|
title=title,
|
|
62
65
|
description="<further optional description to understand the capability, markdown capable text formatting>",
|
|
63
66
|
intent=intent,
|
|
@@ -77,7 +80,8 @@ def _default_epic(title: str, use_default_contribution: bool) -> EpicArtefact:
|
|
|
77
80
|
"<rule needed to fulfill the wanted product behavior>"
|
|
78
81
|
]
|
|
79
82
|
return EpicArtefact(
|
|
80
|
-
tags=[
|
|
83
|
+
tags=[],
|
|
84
|
+
author="creator_unknown",
|
|
81
85
|
title=title,
|
|
82
86
|
description="<further optional description to understand the epic, markdown capable text formatting>",
|
|
83
87
|
intent=intent,
|
|
@@ -98,7 +102,8 @@ def _default_userstory(title: str, use_default_contribution: bool) -> UserstoryA
|
|
|
98
102
|
"<rule needed to fulfill the wanted product behavior>"
|
|
99
103
|
]
|
|
100
104
|
return UserstoryArtefact(
|
|
101
|
-
tags=[
|
|
105
|
+
tags=[],
|
|
106
|
+
author="creator_unknown",
|
|
102
107
|
title=title,
|
|
103
108
|
description="<further optional description to understand the userstory, markdown capable text formatting>",
|
|
104
109
|
intent=intent,
|
|
@@ -110,7 +115,8 @@ def _default_userstory(title: str, use_default_contribution: bool) -> UserstoryA
|
|
|
110
115
|
|
|
111
116
|
def _default_example(title: str, use_default_contribution: bool) -> ExampleArtefact:
|
|
112
117
|
return ExampleArtefact(
|
|
113
|
-
tags=[
|
|
118
|
+
tags=[],
|
|
119
|
+
author="creator_unknown",
|
|
114
120
|
title=title,
|
|
115
121
|
description="<further optional description to understand the example, markdown capable text formatting>",
|
|
116
122
|
contribution=default_contribution() if use_default_contribution else None
|
|
@@ -130,7 +136,8 @@ def _default_keyfeature(title: str, use_default_contribution: bool) -> Keyfeatur
|
|
|
130
136
|
THEN some result is to be expected
|
|
131
137
|
AND some other result is to be expected>"""
|
|
132
138
|
return KeyfeatureArtefact(
|
|
133
|
-
tags=[
|
|
139
|
+
tags=[],
|
|
140
|
+
author="creator_unknown",
|
|
134
141
|
title=title,
|
|
135
142
|
description=description,
|
|
136
143
|
intent=intent,
|
|
@@ -185,7 +192,8 @@ def _default_feature(title: str, use_default_contribution: bool) -> FeatureArtef
|
|
|
185
192
|
description = """<further optional description to understand the feature, no format defined, the example artefact is only a placeholder>"""
|
|
186
193
|
|
|
187
194
|
return FeatureArtefact(
|
|
188
|
-
tags=[
|
|
195
|
+
tags=[],
|
|
196
|
+
author="creator_unknown",
|
|
189
197
|
title=title,
|
|
190
198
|
description=description,
|
|
191
199
|
intent=intent,
|
|
@@ -196,7 +204,8 @@ def _default_feature(title: str, use_default_contribution: bool) -> FeatureArtef
|
|
|
196
204
|
|
|
197
205
|
def _default_task(title: str, use_default_contribution: bool) -> TaskArtefact:
|
|
198
206
|
return TaskArtefact(
|
|
199
|
-
|
|
207
|
+
tags=[],
|
|
208
|
+
status=None,
|
|
200
209
|
title=title,
|
|
201
210
|
description="<further optional description to understand the task, no format defined>",
|
|
202
211
|
contribution=default_contribution() if use_default_contribution else None
|
|
@@ -214,7 +223,8 @@ def _default_issue(title: str, use_default_contribution: bool) -> IssueArtefact:
|
|
|
214
223
|
*or optional free text description*"""
|
|
215
224
|
|
|
216
225
|
return IssueArtefact(
|
|
217
|
-
tags=[
|
|
226
|
+
tags=[],
|
|
227
|
+
author="creator_unknown",
|
|
218
228
|
title=title,
|
|
219
229
|
description=description,
|
|
220
230
|
additional_description=additional_description,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from ara_cli.artefact_models.artefact_model import Artefact, ArtefactType, Intent
|
|
2
|
-
from pydantic import Field, field_validator
|
|
2
|
+
from pydantic import Field, field_validator, model_validator
|
|
3
3
|
from typing import List, Tuple, Optional
|
|
4
4
|
|
|
5
5
|
|
|
@@ -91,6 +91,15 @@ class EpicArtefact(Artefact):
|
|
|
91
91
|
description="Rules the epic defines. It is recommended to create rules to clarify the desired outcome"
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
+
@model_validator(mode='after')
|
|
95
|
+
def check_for_misplaced_rules(self) -> 'EpicArtefact':
|
|
96
|
+
if self.description:
|
|
97
|
+
desc_lines = self.description.split('\n')
|
|
98
|
+
for line in desc_lines:
|
|
99
|
+
if line.strip().startswith("Rule:"):
|
|
100
|
+
raise ValueError("Found 'Rule:' inside description. Rules must be defined before the 'Description:' section.")
|
|
101
|
+
return self
|
|
102
|
+
|
|
94
103
|
@field_validator('artefact_type')
|
|
95
104
|
def validate_artefact_type(cls, v):
|
|
96
105
|
if v != ArtefactType.epic:
|
|
@@ -166,4 +175,4 @@ class EpicArtefact(Artefact):
|
|
|
166
175
|
lines.append("")
|
|
167
176
|
lines.append(description)
|
|
168
177
|
lines.append("")
|
|
169
|
-
return "\n".join(lines)
|
|
178
|
+
return "\n".join(lines)
|
|
@@ -301,6 +301,36 @@ class FeatureArtefact(Artefact):
|
|
|
301
301
|
f"FeatureArtefact must have artefact_type of '{ArtefactType.feature}', not '{v}'")
|
|
302
302
|
return v
|
|
303
303
|
|
|
304
|
+
@classmethod
|
|
305
|
+
def _deserialize_description(cls, lines: List[str]) -> (Optional[str], List[str]):
|
|
306
|
+
description_start = cls._description_starts_with()
|
|
307
|
+
scenario_markers = ["Scenario:", "Scenario Outline:"]
|
|
308
|
+
|
|
309
|
+
start_index = -1
|
|
310
|
+
for i, line in enumerate(lines):
|
|
311
|
+
if line.startswith(description_start):
|
|
312
|
+
start_index = i
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
if start_index == -1:
|
|
316
|
+
return None, lines
|
|
317
|
+
|
|
318
|
+
end_index = len(lines)
|
|
319
|
+
for i in range(start_index + 1, len(lines)):
|
|
320
|
+
if any(lines[i].startswith(marker) for marker in scenario_markers):
|
|
321
|
+
end_index = i
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
first_line_content = lines[start_index][len(description_start):].strip()
|
|
325
|
+
|
|
326
|
+
description_lines_list = [first_line_content] if first_line_content else []
|
|
327
|
+
description_lines_list.extend(lines[start_index+1:end_index])
|
|
328
|
+
|
|
329
|
+
description = "\n".join(description_lines_list).strip() or None
|
|
330
|
+
|
|
331
|
+
remaining_lines = lines[:start_index] + lines[end_index:]
|
|
332
|
+
|
|
333
|
+
return description, remaining_lines
|
|
304
334
|
|
|
305
335
|
@classmethod
|
|
306
336
|
def _title_prefix(cls) -> str:
|
|
@@ -519,4 +549,4 @@ class FeatureArtefact(Artefact):
|
|
|
519
549
|
# or the placeholder is at the end of a line (e.g., "Then I see... __PLACEHOLDER__").
|
|
520
550
|
step = step.replace(key, value)
|
|
521
551
|
rehydrated_steps.append(step)
|
|
522
|
-
return rehydrated_steps
|
|
552
|
+
return rehydrated_steps
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from ara_cli.artefact_models.artefact_model import Artefact, ArtefactType, Intent
|
|
2
|
-
from pydantic import Field, field_validator
|
|
2
|
+
from pydantic import Field, field_validator, model_validator
|
|
3
3
|
from typing import List, Tuple
|
|
4
4
|
|
|
5
5
|
|
|
@@ -92,6 +92,18 @@ class UserstoryArtefact(Artefact):
|
|
|
92
92
|
default_factory=list,
|
|
93
93
|
description="Rules the userstory defines. It is recommended to create rules to clarify the desired outcome")
|
|
94
94
|
|
|
95
|
+
@model_validator(mode='after')
|
|
96
|
+
def check_for_misplaced_content(self) -> 'UserstoryArtefact':
|
|
97
|
+
if self.description:
|
|
98
|
+
desc_lines = self.description.split('\n')
|
|
99
|
+
for line in desc_lines:
|
|
100
|
+
stripped_line = line.strip()
|
|
101
|
+
if stripped_line.startswith("Rule:"):
|
|
102
|
+
raise ValueError("Found 'Rule:' inside description. Rules must be defined before the 'Description:' section.")
|
|
103
|
+
if stripped_line.startswith("Estimate:"):
|
|
104
|
+
raise ValueError("Found 'Estimate:' inside description. Estimate must be defined before the 'Description:' section.")
|
|
105
|
+
return self
|
|
106
|
+
|
|
95
107
|
@field_validator('artefact_type')
|
|
96
108
|
def validate_artefact_type(cls, v):
|
|
97
109
|
if v != ArtefactType.userstory:
|
|
@@ -171,7 +183,7 @@ class UserstoryArtefact(Artefact):
|
|
|
171
183
|
rules = self._serialize_rules()
|
|
172
184
|
|
|
173
185
|
lines = []
|
|
174
|
-
if self.tags
|
|
186
|
+
if tags: # Changed from self.tags to tags to include all tag types
|
|
175
187
|
lines.append(tags)
|
|
176
188
|
lines.append(title)
|
|
177
189
|
lines.append("")
|
|
@@ -188,4 +200,4 @@ class UserstoryArtefact(Artefact):
|
|
|
188
200
|
lines.append(description)
|
|
189
201
|
lines.append("")
|
|
190
202
|
|
|
191
|
-
return '\n'.join(lines)
|
|
203
|
+
return '\n'.join(lines)
|
ara_cli/artefact_scan.py
CHANGED
|
@@ -25,10 +25,10 @@ def is_rule_valid(contribution, classified_artefact_info) -> bool:
|
|
|
25
25
|
if not rule:
|
|
26
26
|
return True
|
|
27
27
|
parent = ArtefactReader.read_artefact(contribution.artefact_name, contribution.classifier)
|
|
28
|
-
if not parent
|
|
28
|
+
if not parent:
|
|
29
29
|
return True
|
|
30
30
|
rules = parent.rules
|
|
31
|
-
if rule not in rules:
|
|
31
|
+
if not rules or rule not in rules:
|
|
32
32
|
return False
|
|
33
33
|
return True
|
|
34
34
|
|