titan-cli 0.1.5__py3-none-any.whl → 0.1.7__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.
- titan_cli/core/workflows/project_step_source.py +52 -7
- titan_cli/core/workflows/workflow_filter_service.py +6 -4
- titan_cli/engine/__init__.py +5 -1
- titan_cli/engine/results.py +31 -1
- titan_cli/engine/steps/ai_assistant_step.py +18 -18
- titan_cli/engine/workflow_executor.py +7 -2
- titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
- titan_cli/ui/tui/screens/workflow_execution.py +22 -24
- titan_cli/ui/tui/screens/workflows.py +8 -4
- titan_cli/ui/tui/textual_components.py +293 -189
- titan_cli/ui/tui/textual_workflow_executor.py +30 -2
- titan_cli/ui/tui/theme.py +34 -5
- titan_cli/ui/tui/widgets/__init__.py +15 -0
- titan_cli/ui/tui/widgets/multiline_input.py +32 -0
- titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
- titan_cli/ui/tui/widgets/prompt_input.py +74 -0
- titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
- titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
- titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
- titan_cli/ui/tui/widgets/text.py +51 -130
- {titan_cli-0.1.5.dist-info → titan_cli-0.1.7.dist-info}/METADATA +5 -10
- {titan_cli-0.1.5.dist-info → titan_cli-0.1.7.dist-info}/RECORD +54 -41
- {titan_cli-0.1.5.dist-info → titan_cli-0.1.7.dist-info}/WHEEL +1 -1
- titan_plugin_git/clients/git_client.py +59 -2
- titan_plugin_git/plugin.py +10 -0
- titan_plugin_git/steps/ai_commit_message_step.py +8 -8
- titan_plugin_git/steps/branch_steps.py +6 -6
- titan_plugin_git/steps/checkout_step.py +66 -0
- titan_plugin_git/steps/commit_step.py +3 -3
- titan_plugin_git/steps/create_branch_step.py +131 -0
- titan_plugin_git/steps/diff_summary_step.py +11 -13
- titan_plugin_git/steps/pull_step.py +70 -0
- titan_plugin_git/steps/push_step.py +3 -3
- titan_plugin_git/steps/restore_original_branch_step.py +97 -0
- titan_plugin_git/steps/save_current_branch_step.py +82 -0
- titan_plugin_git/steps/status_step.py +23 -13
- titan_plugin_git/workflows/commit-ai.yaml +4 -3
- titan_plugin_github/steps/ai_pr_step.py +90 -22
- titan_plugin_github/steps/create_pr_step.py +8 -8
- titan_plugin_github/steps/github_prompt_steps.py +13 -13
- titan_plugin_github/steps/issue_steps.py +14 -15
- titan_plugin_github/steps/preview_step.py +8 -8
- titan_plugin_github/workflows/create-pr-ai.yaml +1 -11
- titan_plugin_jira/messages.py +12 -0
- titan_plugin_jira/plugin.py +4 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +6 -6
- titan_plugin_jira/steps/get_issue_step.py +7 -7
- titan_plugin_jira/steps/list_versions_step.py +133 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +8 -9
- titan_plugin_jira/steps/search_jql_step.py +191 -0
- titan_plugin_jira/steps/search_saved_query_step.py +13 -13
- titan_plugin_jira/utils/__init__.py +1 -1
- {titan_cli-0.1.5.dist-info/licenses → titan_cli-0.1.7.dist-info}/LICENSE +0 -0
- {titan_cli-0.1.5.dist-info → titan_cli-0.1.7.dist-info}/entry_points.txt +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import importlib.util
|
|
2
|
+
import sys
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from typing import Callable, Dict, Any, Optional, List
|
|
4
5
|
from dataclasses import dataclass
|
|
@@ -31,6 +32,7 @@ class BaseStepSource:
|
|
|
31
32
|
def discover(self) -> List[StepInfo]:
|
|
32
33
|
"""
|
|
33
34
|
Discovers all available step files in the project's .titan/steps directory.
|
|
35
|
+
Supports subdirectories (e.g., .titan/steps/jira/step.py).
|
|
34
36
|
"""
|
|
35
37
|
if self._step_info_cache is not None:
|
|
36
38
|
return self._step_info_cache
|
|
@@ -40,18 +42,18 @@ class BaseStepSource:
|
|
|
40
42
|
return []
|
|
41
43
|
|
|
42
44
|
discovered = []
|
|
43
|
-
for step_file in self._steps_dir.glob("
|
|
44
|
-
if step_file.name not in self.EXCLUDED_FILES:
|
|
45
|
+
for step_file in self._steps_dir.glob("**/*.py"):
|
|
46
|
+
if step_file.name not in self.EXCLUDED_FILES and not any(part.startswith("__") for part in step_file.parts):
|
|
45
47
|
step_name = step_file.stem
|
|
46
48
|
discovered.append(StepInfo(name=step_name, path=step_file))
|
|
47
|
-
|
|
49
|
+
|
|
48
50
|
self._step_info_cache = discovered
|
|
49
51
|
return discovered
|
|
50
52
|
|
|
51
53
|
def get_step(self, step_name: str) -> Optional[StepFunction]:
|
|
52
54
|
"""
|
|
53
55
|
Retrieves a step function by name, loading it from its file if necessary.
|
|
54
|
-
Searches all Python files in the directory for the function.
|
|
56
|
+
Searches all Python files in the directory (including subdirectories) for the function.
|
|
55
57
|
"""
|
|
56
58
|
if step_name in self._step_function_cache:
|
|
57
59
|
return self._step_function_cache[step_name]
|
|
@@ -59,9 +61,9 @@ class BaseStepSource:
|
|
|
59
61
|
if not self._steps_dir.is_dir():
|
|
60
62
|
return None
|
|
61
63
|
|
|
62
|
-
# Search all Python files for the function
|
|
63
|
-
for step_file in self._steps_dir.glob("
|
|
64
|
-
if step_file.name in self.EXCLUDED_FILES:
|
|
64
|
+
# Search all Python files (including subdirectories) for the function
|
|
65
|
+
for step_file in self._steps_dir.glob("**/*.py"):
|
|
66
|
+
if step_file.name in self.EXCLUDED_FILES or any(part.startswith("__") for part in step_file.parts):
|
|
65
67
|
continue
|
|
66
68
|
|
|
67
69
|
try:
|
|
@@ -88,11 +90,54 @@ class BaseStepSource:
|
|
|
88
90
|
class ProjectStepSource(BaseStepSource):
|
|
89
91
|
"""
|
|
90
92
|
Discovers and loads Python step functions from a project's .titan/steps/ directory.
|
|
93
|
+
Supports relative imports by properly setting module __package__ attribute.
|
|
91
94
|
"""
|
|
92
95
|
def __init__(self, project_root: Path):
|
|
93
96
|
steps_dir = project_root / ".titan" / "steps"
|
|
94
97
|
super().__init__(steps_dir)
|
|
95
98
|
self._project_root = project_root
|
|
99
|
+
self._titan_dir = project_root / ".titan"
|
|
100
|
+
|
|
101
|
+
# Add .titan directory to sys.path to enable absolute imports
|
|
102
|
+
titan_dir_str = str(self._titan_dir)
|
|
103
|
+
if titan_dir_str not in sys.path:
|
|
104
|
+
sys.path.insert(0, titan_dir_str)
|
|
105
|
+
|
|
106
|
+
def get_step(self, step_name: str) -> Optional[StepFunction]:
|
|
107
|
+
"""
|
|
108
|
+
Retrieves a step function by name.
|
|
109
|
+
Project steps should use absolute imports from .titan/ as the root.
|
|
110
|
+
"""
|
|
111
|
+
if step_name in self._step_function_cache:
|
|
112
|
+
return self._step_function_cache[step_name]
|
|
113
|
+
|
|
114
|
+
if not self._steps_dir.is_dir():
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
# Search all Python files for the function
|
|
118
|
+
for step_file in self._steps_dir.glob("**/*.py"):
|
|
119
|
+
if step_file.name in self.EXCLUDED_FILES or any(part.startswith("__") for part in step_file.parts):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Use a unique module name to avoid conflicts
|
|
124
|
+
module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
|
|
125
|
+
spec = importlib.util.spec_from_file_location(module_name, step_file)
|
|
126
|
+
if spec and spec.loader:
|
|
127
|
+
module = importlib.util.module_from_spec(spec)
|
|
128
|
+
spec.loader.exec_module(module)
|
|
129
|
+
|
|
130
|
+
# Look for the function
|
|
131
|
+
step_func = getattr(module, step_name, None)
|
|
132
|
+
if callable(step_func):
|
|
133
|
+
self._step_function_cache[step_name] = step_func
|
|
134
|
+
return step_func
|
|
135
|
+
|
|
136
|
+
except Exception:
|
|
137
|
+
# Continue searching other files
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
return None
|
|
96
141
|
|
|
97
142
|
|
|
98
143
|
class UserStepSource(BaseStepSource):
|
|
@@ -45,10 +45,12 @@ class WorkflowFilterService:
|
|
|
45
45
|
if wf_info.required_plugins:
|
|
46
46
|
# Filter out core/project pseudo-plugins
|
|
47
47
|
real_plugins = [p for p in wf_info.required_plugins if p not in ["core", "project"]]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
# If workflow uses multiple plugins, treat as custom/project workflow
|
|
49
|
+
if len(real_plugins) > 1:
|
|
50
|
+
return "Project"
|
|
51
|
+
elif len(real_plugins) == 1:
|
|
52
|
+
# Single plugin dependency
|
|
53
|
+
return real_plugins[0].capitalize()
|
|
52
54
|
# No plugin dependencies, it's a custom workflow
|
|
53
55
|
return "Custom"
|
|
54
56
|
|
titan_cli/engine/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ This module provides the execution engine for composing and running workflows
|
|
|
5
5
|
using the Atomic Steps Pattern.
|
|
6
6
|
|
|
7
7
|
Core components:
|
|
8
|
-
- WorkflowResult types (Success, Error, Skip)
|
|
8
|
+
- WorkflowResult types (Success, Error, Skip, Exit)
|
|
9
9
|
- WorkflowContext for dependency injection
|
|
10
10
|
- WorkflowContextBuilder for fluent API
|
|
11
11
|
- WorkflowExecutor for executing YAML workflows (see workflow_executor.py)
|
|
@@ -16,9 +16,11 @@ from .results import (
|
|
|
16
16
|
Success,
|
|
17
17
|
Error,
|
|
18
18
|
Skip,
|
|
19
|
+
Exit,
|
|
19
20
|
is_success,
|
|
20
21
|
is_error,
|
|
21
22
|
is_skip,
|
|
23
|
+
is_exit,
|
|
22
24
|
)
|
|
23
25
|
from .context import WorkflowContext
|
|
24
26
|
from .builder import WorkflowContextBuilder
|
|
@@ -29,10 +31,12 @@ __all__ = [
|
|
|
29
31
|
"Success",
|
|
30
32
|
"Error",
|
|
31
33
|
"Skip",
|
|
34
|
+
"Exit",
|
|
32
35
|
# Helper functions
|
|
33
36
|
"is_success",
|
|
34
37
|
"is_error",
|
|
35
38
|
"is_skip",
|
|
39
|
+
"is_exit",
|
|
36
40
|
# Context & builder
|
|
37
41
|
"WorkflowContext",
|
|
38
42
|
"WorkflowContextBuilder",
|
titan_cli/engine/results.py
CHANGED
|
@@ -68,8 +68,33 @@ class Skip:
|
|
|
68
68
|
metadata: Optional[dict[str, Any]] = None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Exit:
|
|
73
|
+
"""
|
|
74
|
+
Exit the entire workflow early (not an error).
|
|
75
|
+
|
|
76
|
+
Use when the workflow should stop because it's not needed:
|
|
77
|
+
- No changes to commit
|
|
78
|
+
- Nothing to do
|
|
79
|
+
- Preconditions not met
|
|
80
|
+
|
|
81
|
+
This exits the ENTIRE workflow, not just the current step.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
message: Why the workflow is exiting (required)
|
|
85
|
+
metadata: Metadata to auto-merge into ctx.data
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> if not has_changes:
|
|
89
|
+
>>> return Exit("No changes to commit")
|
|
90
|
+
>>> return Exit("Already up to date", metadata={"status": "clean"})
|
|
91
|
+
"""
|
|
92
|
+
message: str
|
|
93
|
+
metadata: Optional[dict[str, Any]] = None
|
|
94
|
+
|
|
95
|
+
|
|
71
96
|
# Type alias for workflow results
|
|
72
|
-
WorkflowResult = Union[Success, Error, Skip]
|
|
97
|
+
WorkflowResult = Union[Success, Error, Skip, Exit]
|
|
73
98
|
|
|
74
99
|
|
|
75
100
|
# ============================================================================
|
|
@@ -89,3 +114,8 @@ def is_error(result: WorkflowResult) -> bool:
|
|
|
89
114
|
def is_skip(result: WorkflowResult) -> bool:
|
|
90
115
|
"""Check if result is Skip."""
|
|
91
116
|
return isinstance(result, Skip)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_exit(result: WorkflowResult) -> bool:
|
|
120
|
+
"""Check if result is Exit."""
|
|
121
|
+
return isinstance(result, Exit)
|
|
@@ -56,13 +56,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
56
56
|
# Validate cli_preference
|
|
57
57
|
VALID_CLI_PREFERENCES = {"auto", "claude", "gemini"}
|
|
58
58
|
if cli_preference not in VALID_CLI_PREFERENCES:
|
|
59
|
-
ctx.textual.
|
|
59
|
+
ctx.textual.error_text(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
|
|
60
60
|
ctx.textual.end_step("error")
|
|
61
61
|
return Error(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
|
|
62
62
|
|
|
63
63
|
# Validate required parameters
|
|
64
64
|
if not context_key:
|
|
65
|
-
ctx.textual.
|
|
65
|
+
ctx.textual.error_text(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
|
|
66
66
|
ctx.textual.end_step("error")
|
|
67
67
|
return Error(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
|
|
68
68
|
|
|
@@ -79,7 +79,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
79
79
|
else:
|
|
80
80
|
friendly_msg = "No issues to fix - skipping AI assistance"
|
|
81
81
|
|
|
82
|
-
ctx.textual.
|
|
82
|
+
ctx.textual.dim_text(friendly_msg)
|
|
83
83
|
ctx.textual.end_step("skip")
|
|
84
84
|
return Skip(friendly_msg)
|
|
85
85
|
|
|
@@ -96,11 +96,11 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
96
96
|
context_str = json.dumps(context_data, indent=2)
|
|
97
97
|
prompt = prompt_template.format(context=context_str)
|
|
98
98
|
except KeyError as e:
|
|
99
|
-
ctx.textual.
|
|
99
|
+
ctx.textual.error_text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
|
|
100
100
|
ctx.textual.end_step("error")
|
|
101
101
|
return Error(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
|
|
102
102
|
except Exception as e:
|
|
103
|
-
ctx.textual.
|
|
103
|
+
ctx.textual.error_text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
|
|
104
104
|
ctx.textual.end_step("error")
|
|
105
105
|
return Error(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
|
|
106
106
|
|
|
@@ -113,10 +113,10 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
113
113
|
)
|
|
114
114
|
if not should_launch:
|
|
115
115
|
if fail_on_decline:
|
|
116
|
-
ctx.textual.
|
|
116
|
+
ctx.textual.warning_text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
|
|
117
117
|
ctx.textual.end_step("error")
|
|
118
118
|
return Error(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
|
|
119
|
-
ctx.textual.
|
|
119
|
+
ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
120
120
|
ctx.textual.end_step("skip")
|
|
121
121
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
122
122
|
|
|
@@ -142,7 +142,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
142
142
|
available_launchers[cli_name] = launcher
|
|
143
143
|
|
|
144
144
|
if not available_launchers:
|
|
145
|
-
ctx.textual.
|
|
145
|
+
ctx.textual.warning_text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
|
|
146
146
|
ctx.textual.end_step("skip")
|
|
147
147
|
return Skip(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
|
|
148
148
|
|
|
@@ -151,7 +151,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
151
151
|
else:
|
|
152
152
|
# Show available CLIs with numbers
|
|
153
153
|
ctx.textual.text("") # spacing
|
|
154
|
-
ctx.textual.
|
|
154
|
+
ctx.textual.bold_primary_text(msg.AIAssistant.SELECT_ASSISTANT_CLI)
|
|
155
155
|
|
|
156
156
|
cli_options = list(available_launchers.keys())
|
|
157
157
|
for idx, cli_name in enumerate(cli_options, 1):
|
|
@@ -162,7 +162,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
162
162
|
choice_str = ctx.textual.ask_text("Select option (or press Enter to cancel):", default="")
|
|
163
163
|
|
|
164
164
|
if not choice_str or choice_str.strip() == "":
|
|
165
|
-
ctx.textual.
|
|
165
|
+
ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
166
166
|
ctx.textual.end_step("skip")
|
|
167
167
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
168
168
|
|
|
@@ -171,17 +171,17 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
171
171
|
if 0 <= choice_idx < len(cli_options):
|
|
172
172
|
cli_to_launch = cli_options[choice_idx]
|
|
173
173
|
else:
|
|
174
|
-
ctx.textual.
|
|
174
|
+
ctx.textual.error_text("Invalid option selected")
|
|
175
175
|
ctx.textual.end_step("skip")
|
|
176
176
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
177
177
|
except ValueError:
|
|
178
|
-
ctx.textual.
|
|
178
|
+
ctx.textual.error_text("Invalid input - must be a number")
|
|
179
179
|
ctx.textual.end_step("skip")
|
|
180
180
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
181
181
|
|
|
182
182
|
# Validate selection
|
|
183
183
|
if cli_to_launch not in available_launchers:
|
|
184
|
-
ctx.textual.
|
|
184
|
+
ctx.textual.error_text(f"Unknown CLI to launch: {cli_to_launch}")
|
|
185
185
|
ctx.textual.end_step("error")
|
|
186
186
|
return Error(f"Unknown CLI to launch: {cli_to_launch}")
|
|
187
187
|
|
|
@@ -189,13 +189,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
189
189
|
|
|
190
190
|
# Launch the CLI
|
|
191
191
|
ctx.textual.text("") # spacing
|
|
192
|
-
ctx.textual.
|
|
192
|
+
ctx.textual.primary_text(msg.AIAssistant.LAUNCHING_ASSISTANT.format(cli_name=cli_name))
|
|
193
193
|
|
|
194
194
|
# Show prompt preview
|
|
195
195
|
prompt_preview_text = msg.AIAssistant.PROMPT_PREVIEW.format(
|
|
196
196
|
prompt_preview=f"{prompt[:100]}..." if len(prompt) > 100 else prompt
|
|
197
197
|
)
|
|
198
|
-
ctx.textual.
|
|
198
|
+
ctx.textual.dim_text(prompt_preview_text)
|
|
199
199
|
ctx.textual.text("") # spacing
|
|
200
200
|
|
|
201
201
|
project_root = ctx.get("project_root", ".")
|
|
@@ -208,13 +208,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
208
208
|
)
|
|
209
209
|
|
|
210
210
|
ctx.textual.text("") # spacing
|
|
211
|
-
ctx.textual.
|
|
211
|
+
ctx.textual.success_text(msg.AIAssistant.BACK_IN_TITAN)
|
|
212
212
|
|
|
213
213
|
if exit_code != 0:
|
|
214
|
-
ctx.textual.
|
|
214
|
+
ctx.textual.warning_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
|
|
215
215
|
ctx.textual.end_step("error")
|
|
216
216
|
return Error(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
|
|
217
217
|
|
|
218
|
-
ctx.textual.
|
|
218
|
+
ctx.textual.success_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
|
|
219
219
|
ctx.textual.end_step("success")
|
|
220
220
|
return Success(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), metadata={"ai_exit_code": exit_code})
|
|
@@ -4,7 +4,7 @@ from typing import Any, Dict, Optional
|
|
|
4
4
|
from titan_cli.core.workflows import ParsedWorkflow
|
|
5
5
|
from titan_cli.core.workflows.workflow_exceptions import WorkflowExecutionError
|
|
6
6
|
from titan_cli.engine.context import WorkflowContext
|
|
7
|
-
from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip
|
|
7
|
+
from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip, is_exit
|
|
8
8
|
from titan_cli.core.workflows.workflow_registry import WorkflowRegistry
|
|
9
9
|
from titan_cli.core.plugins.plugin_registry import PluginRegistry
|
|
10
10
|
from titan_cli.core.workflows.models import WorkflowStepModel
|
|
@@ -75,7 +75,12 @@ class WorkflowExecutor:
|
|
|
75
75
|
step_result = Error(f"An unexpected error occurred in step '{step_name}': {e}", e)
|
|
76
76
|
|
|
77
77
|
# Handle step result
|
|
78
|
-
if
|
|
78
|
+
if is_exit(step_result):
|
|
79
|
+
# Exit immediately - workflow is done (not an error)
|
|
80
|
+
if step_result.metadata:
|
|
81
|
+
ctx.data.update(step_result.metadata)
|
|
82
|
+
return Success(step_result.message, step_result.metadata)
|
|
83
|
+
elif is_error(step_result):
|
|
79
84
|
if step_config.on_error == "fail":
|
|
80
85
|
return Error(f"Workflow failed at step '{step_name}'", step_result.exception)
|
|
81
86
|
# else: on_error == "continue" - continue to next step
|
|
@@ -567,6 +567,22 @@ class PluginConfigWizardScreen(BaseScreen):
|
|
|
567
567
|
# Save secrets
|
|
568
568
|
project_name = self.config.get_project_name()
|
|
569
569
|
for secret_key, secret_value in secrets_to_save.items():
|
|
570
|
+
# Clean up old secrets for this key to avoid conflicts
|
|
571
|
+
# This prevents accumulation of stale tokens from previous configurations
|
|
572
|
+
if self.plugin_name == "jira" and "api_token" in secret_key:
|
|
573
|
+
# Delete all possible old JIRA token variants
|
|
574
|
+
old_keys = [
|
|
575
|
+
secret_key, # Generic: jira_api_token
|
|
576
|
+
f"{project_name}_{secret_key}" if project_name else None, # Project-specific
|
|
577
|
+
]
|
|
578
|
+
for old_key in old_keys:
|
|
579
|
+
if old_key:
|
|
580
|
+
try:
|
|
581
|
+
self.config.secrets.delete(old_key, scope="user")
|
|
582
|
+
except Exception:
|
|
583
|
+
pass # Ignore errors if key doesn't exist
|
|
584
|
+
|
|
585
|
+
# Save the new secret
|
|
570
586
|
keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
|
|
571
587
|
self.config.secrets.set(keychain_key, secret_value, scope="user")
|
|
572
588
|
|
|
@@ -146,16 +146,16 @@ class WorkflowExecutionScreen(BaseScreen):
|
|
|
146
146
|
thread=True
|
|
147
147
|
)
|
|
148
148
|
|
|
149
|
-
except (WorkflowNotFoundError, WorkflowExecutionError):
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
149
|
+
except (WorkflowNotFoundError, WorkflowExecutionError) as e:
|
|
150
|
+
self._output(f"[red]{Icons.ERROR} Error loading workflow: {e}[/red]")
|
|
151
|
+
self._output("[dim]Press ESC or Q to return[/dim]")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
import traceback
|
|
154
|
+
error_details = traceback.format_exc()
|
|
155
|
+
self._output(f"[red]{Icons.ERROR} Unexpected error loading workflow:[/red]")
|
|
156
|
+
self._output(f"[red]{type(e).__name__}: {e}[/red]")
|
|
157
|
+
self._output(f"[dim]{error_details}[/dim]")
|
|
158
|
+
self._output("[dim]Press ESC or Q to return[/dim]")
|
|
159
159
|
|
|
160
160
|
def _execute_workflow(self) -> None:
|
|
161
161
|
"""Execute the workflow in a background thread."""
|
|
@@ -493,12 +493,14 @@ class WorkflowExecutionContent(Widget):
|
|
|
493
493
|
|
|
494
494
|
def on_descendant_mount(self, event) -> None:
|
|
495
495
|
"""Auto-scroll when any widget is mounted as a descendant."""
|
|
496
|
-
|
|
497
|
-
from titan_cli.ui.tui.textual_components import PromptInput
|
|
496
|
+
from titan_cli.ui.tui.widgets import PromptInput, StepContainer
|
|
498
497
|
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
-
if
|
|
498
|
+
# Auto-scroll for StepContainers (new steps being added)
|
|
499
|
+
# StepContainer itself triggers scroll via begin_step(), but this is a safety net
|
|
500
|
+
if isinstance(event.widget, StepContainer):
|
|
501
|
+
self._scroll_to_end()
|
|
502
|
+
# Also scroll for other widgets, but skip PromptInput (it handles its own scroll)
|
|
503
|
+
elif not isinstance(event.widget, PromptInput):
|
|
502
504
|
self._scroll_to_end()
|
|
503
505
|
|
|
504
506
|
def handle_event(self, message) -> None:
|
|
@@ -520,13 +522,9 @@ class WorkflowExecutionContent(Widget):
|
|
|
520
522
|
pass
|
|
521
523
|
|
|
522
524
|
elif isinstance(message, TextualWorkflowExecutor.StepFailed):
|
|
523
|
-
#
|
|
524
|
-
#
|
|
525
|
-
|
|
526
|
-
indent = " " * self._workflow_depth if self._workflow_depth > 0 else ""
|
|
527
|
-
self.append_output(f"[yellow]{indent} {Icons.WARNING} Continuing despite error[/yellow]\n")
|
|
528
|
-
else:
|
|
529
|
-
self.append_output("")
|
|
525
|
+
# Steps handle their own error display via ctx.textual
|
|
526
|
+
# Executor only coordinates execution, no visual output needed
|
|
527
|
+
pass
|
|
530
528
|
|
|
531
529
|
elif isinstance(message, TextualWorkflowExecutor.StepSkipped):
|
|
532
530
|
# StepContainer now handles step skips (yellow border), so we don't display the panel
|
|
@@ -566,7 +564,7 @@ class WorkflowExecutionContent(Widget):
|
|
|
566
564
|
pass
|
|
567
565
|
|
|
568
566
|
elif isinstance(message, TextualWorkflowExecutor.WorkflowFailed):
|
|
569
|
-
#
|
|
567
|
+
# Steps handle their own error display via ctx.textual
|
|
568
|
+
# Just show a simple notification without duplicating the error message
|
|
570
569
|
self.app.notify(f"❌ Workflow failed at step: {message.step_name}", severity="error", timeout=10)
|
|
571
|
-
self.append_output(f"[red]{message.error_message}[/red]")
|
|
572
570
|
|
|
@@ -16,6 +16,7 @@ from titan_cli.core.workflows.workflow_filter_service import WorkflowFilterServi
|
|
|
16
16
|
from titan_cli.core.workflows.workflow_sources import WorkflowInfo
|
|
17
17
|
from titan_cli.ui.tui.screens.workflow_execution import WorkflowExecutionScreen
|
|
18
18
|
from titan_cli.ui.tui.icons import Icons
|
|
19
|
+
from titan_cli.ui.tui.widgets import StyledOption
|
|
19
20
|
from .base import BaseScreen
|
|
20
21
|
|
|
21
22
|
class WorkflowsScreen(BaseScreen):
|
|
@@ -170,11 +171,14 @@ class WorkflowsScreen(BaseScreen):
|
|
|
170
171
|
workflows_to_show = workflows
|
|
171
172
|
|
|
172
173
|
for wf_info in workflows_to_show:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
styled_opt = StyledOption(
|
|
175
|
+
id=wf_info.name,
|
|
176
|
+
title=wf_info.name.capitalize(),
|
|
177
|
+
description=wf_info.description
|
|
177
178
|
)
|
|
179
|
+
# Convert StyledOption to Option with markup
|
|
180
|
+
prompt = f"[bold]{styled_opt.title}[/bold]\n[dim]{styled_opt.description}[/dim]"
|
|
181
|
+
options.append(Option(prompt, id=styled_opt.id))
|
|
178
182
|
|
|
179
183
|
return options if options else [Option("No workflows found", id="none", disabled=True)]
|
|
180
184
|
|