titan-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
@@ -0,0 +1,163 @@
1
+ # core/plugin_registry.py
2
+ from importlib.metadata import entry_points
3
+ from typing import Dict, List, Any, Optional
4
+ from ..errors import PluginLoadError, PluginInitializationError
5
+ from .plugin_base import TitanPlugin
6
+
7
+ class PluginRegistry:
8
+ """Discovers and manages installed plugins."""
9
+
10
+ def __init__(self, discover_on_init: bool = True):
11
+ self._plugins: Dict[str, TitanPlugin] = {}
12
+ self._failed_plugins: Dict[str, Exception] = {}
13
+ self._discovered_plugin_names: List[str] = []
14
+ if discover_on_init:
15
+ self.discover()
16
+
17
+ def discover(self):
18
+ """Discover all installed Titan plugins."""
19
+ import logging
20
+ logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
21
+
22
+ discovered = entry_points(group='titan.plugins')
23
+ self._discovered_plugin_names = [ep.name for ep in discovered]
24
+ logger.debug(f"PluginRegistry.discover() - Found {len(self._discovered_plugin_names)} plugins: {self._discovered_plugin_names}")
25
+
26
+ for ep in discovered:
27
+ try:
28
+ logger.debug(f"Loading plugin: {ep.name}")
29
+ plugin_class = ep.load()
30
+ if not issubclass(plugin_class, TitanPlugin):
31
+ raise TypeError("Plugin class must inherit from TitanPlugin")
32
+ self._plugins[ep.name] = plugin_class()
33
+ logger.debug(f"Successfully loaded plugin: {ep.name}")
34
+ except Exception as e:
35
+ logger.error(f"Failed to load plugin {ep.name}: {e}", exc_info=True)
36
+ error = PluginLoadError(plugin_name=ep.name, original_exception=e)
37
+ self._failed_plugins[ep.name] = error
38
+
39
+ logger.debug(f"PluginRegistry.discover() - Loaded {len(self._plugins)} plugins successfully")
40
+ logger.debug(f"PluginRegistry.discover() - Failed {len(self._failed_plugins)} plugins: {list(self._failed_plugins.keys())}")
41
+
42
+ def initialize_plugins(self, config: Any, secrets: Any) -> None:
43
+ """
44
+ Initializes all discovered plugins in dependency order.
45
+
46
+ Args:
47
+ config: TitanConfig instance
48
+ secrets: SecretManager instance
49
+ """
50
+ import logging
51
+ logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
52
+
53
+ # Create a copy of plugin names to iterate over, as _plugins might change
54
+ plugins_to_initialize = list(self._plugins.keys())
55
+ initialized = set()
56
+
57
+ # Simple dependency resolution loop
58
+ while plugins_to_initialize:
59
+ remaining_plugins_count = len(plugins_to_initialize)
60
+ next_pass_plugins = []
61
+
62
+ for name in plugins_to_initialize:
63
+ if name in initialized:
64
+ continue
65
+
66
+ plugin = self._plugins[name]
67
+ dependencies_met = True
68
+
69
+ # Check if all dependencies are initialized or failed
70
+ for dep_name in plugin.dependencies:
71
+ if dep_name not in initialized:
72
+ # If a dependency failed to load/initialize, this plugin also implicitly fails
73
+ if dep_name in self._failed_plugins:
74
+ # Mark this plugin as failed due to dependency
75
+ error = PluginInitializationError(
76
+ plugin_name=name,
77
+ original_exception=f"Dependency '{dep_name}' failed to load/initialize."
78
+ )
79
+ self._failed_plugins[name] = error
80
+ # Don't delete from _plugins - keep it available for configuration
81
+ dependencies_met = False
82
+ break
83
+ else:
84
+ dependencies_met = False
85
+ break
86
+
87
+ if not dependencies_met:
88
+ if name not in self._failed_plugins: # If not already marked failed by dependency
89
+ next_pass_plugins.append(name)
90
+ continue
91
+
92
+ # Initialize the plugin if dependencies are met
93
+ try:
94
+ logger.debug(f"Initializing plugin: {name}")
95
+ plugin.initialize(config, secrets)
96
+ initialized.add(name)
97
+ logger.debug(f"Successfully initialized plugin: {name}")
98
+ except Exception as e:
99
+ logger.error(f"Failed to initialize plugin {name}: {e}", exc_info=True)
100
+ error = PluginInitializationError(plugin_name=name, original_exception=e)
101
+ self._failed_plugins[name] = error
102
+ # Don't delete from _plugins - keep it available for configuration
103
+
104
+ plugins_to_initialize = next_pass_plugins
105
+ if len(plugins_to_initialize) == remaining_plugins_count and remaining_plugins_count > 0:
106
+ # Circular dependency or unresolvable dependency
107
+ for name in plugins_to_initialize:
108
+ if name not in self._failed_plugins: # Only mark if not already failed by dependency
109
+ error = PluginInitializationError(
110
+ plugin_name=name,
111
+ original_exception="Circular or unresolvable dependency detected."
112
+ )
113
+ self._failed_plugins[name] = error
114
+ if name in self._plugins: # Only delete if it wasn't deleted by a dep error
115
+ del self._plugins[name]
116
+ break # Exit the loop if no progress is made
117
+
118
+ def list_installed(self) -> List[str]:
119
+ """List successfully loaded plugins."""
120
+ return list(self._plugins.keys())
121
+
122
+ def list_discovered(self) -> List[str]:
123
+ """List all discovered plugins by name, regardless of load status."""
124
+ return self._discovered_plugin_names
125
+
126
+ def list_enabled(self, config: Any) -> List[str]:
127
+ """
128
+ List plugins that are enabled in the current project configuration.
129
+
130
+ Args:
131
+ config: TitanConfig instance
132
+
133
+ Returns:
134
+ List of enabled plugin names
135
+ """
136
+ if not config or not config.config or not config.config.plugins:
137
+ return []
138
+
139
+ enabled = []
140
+ for plugin_name, plugin_config in config.config.plugins.items():
141
+ if hasattr(plugin_config, 'enabled') and plugin_config.enabled:
142
+ enabled.append(plugin_name)
143
+
144
+ return enabled
145
+
146
+ def list_failed(self) -> Dict[str, Exception]:
147
+ """
148
+ List plugins that failed to load or initialize.
149
+
150
+ Returns:
151
+ Dict mapping plugin name to error
152
+ """
153
+ return self._failed_plugins.copy()
154
+
155
+ def get_plugin(self, name: str) -> Optional[TitanPlugin]:
156
+ """Get plugin instance by name."""
157
+ return self._plugins.get(name)
158
+
159
+ def reset(self):
160
+ """Resets the registry, clearing all loaded plugins and re-discovering."""
161
+ self._plugins.clear()
162
+ self._failed_plugins.clear()
163
+ self.discover()
@@ -0,0 +1,141 @@
1
+ # titan_cli/core/secrets.py
2
+ import os
3
+ import keyring
4
+ from pathlib import Path
5
+ from typing import Optional, Literal
6
+ from dotenv import load_dotenv
7
+
8
+ ScopeType = Literal["env", "project", "user"]
9
+
10
+ class SecretManager:
11
+ """
12
+ Manages secrets with a 3-level cascade:
13
+
14
+ 1. Environment variables (HIGHEST - CI/CD)
15
+ 2. Project secrets (.titan/secrets.env - team-shared)
16
+ 3. System keyring (USER - personal credentials)
17
+ """
18
+
19
+ def __init__(self, project_path: Optional[Path] = None):
20
+ self.project_path = project_path or Path.cwd()
21
+ self._load_project_secrets()
22
+
23
+ def _load_project_secrets(self):
24
+ """Load secrets from .titan/secrets.env"""
25
+ secrets_file = self.project_path / ".titan" / "secrets.env"
26
+ if secrets_file.exists():
27
+ load_dotenv(secrets_file)
28
+
29
+ def get(self, key: str, namespace: str = "titan") -> Optional[str]:
30
+ """
31
+ Get secret with cascading priority
32
+
33
+ Priority:
34
+ 1. Environment variable (e.g., GITHUB_TOKEN, includes project secrets loaded at init)
35
+ 2. System keyring (user-level)
36
+ 3. None
37
+
38
+ Note: Project secrets (.titan/secrets.env) are loaded
39
+ into environment on init, so they are checked in step 1.
40
+ """
41
+ # 1. Environment variable (includes project secrets)
42
+ env_key = key.upper()
43
+ if env_key in os.environ:
44
+ return os.environ[env_key]
45
+
46
+ # 2. System keyring
47
+ try:
48
+ value = keyring.get_password(namespace, key)
49
+ if value:
50
+ return value
51
+ except Exception:
52
+ pass # Keyring might not be available
53
+
54
+ return None
55
+
56
+ def set(
57
+ self,
58
+ key: str,
59
+ value: str,
60
+ namespace: str = "titan",
61
+ scope: ScopeType = "user"
62
+ ):
63
+ """
64
+ Set secret
65
+
66
+ Args:
67
+ key: Secret key (e.g., "anthropic_api_key")
68
+ value: Secret value
69
+ namespace: Keyring namespace
70
+ scope: Where to store:
71
+ - "env": Current environment only (temporary)
72
+ - "project": .titan/secrets.env (team-shared)
73
+ - "user": System keyring (personal, secure)
74
+ """
75
+ if scope == "env":
76
+ # Set in current environment only
77
+ os.environ[key.upper()] = value
78
+
79
+ elif scope == "user":
80
+ # Store in system keyring (most secure)
81
+ try:
82
+ keyring.set_password(namespace, key, value)
83
+ except Exception:
84
+ # Fallback to project scope if keyring fails (common on macOS with unsigned apps)
85
+ # Recursively call with project scope
86
+ self.set(key, value, scope="project")
87
+
88
+ elif scope == "project":
89
+ # Store in .titan/secrets.env
90
+ secrets_file = self.project_path / ".titan" / "secrets.env"
91
+ secrets_file.parent.mkdir(parents=True, exist_ok=True)
92
+
93
+ # Read existing content
94
+ existing_lines = []
95
+ if secrets_file.exists():
96
+ with open(secrets_file, "r") as f:
97
+ existing_lines = f.readlines()
98
+
99
+ # Update or append
100
+ key_upper = key.upper()
101
+ updated = False
102
+ for i, line in enumerate(existing_lines):
103
+ if line.startswith(f"{key_upper}="):
104
+ existing_lines[i] = f"{key_upper}='{value}'\n"
105
+ updated = True
106
+ break
107
+
108
+ if not updated:
109
+ existing_lines.append(f"{key_upper}='{value}'\n")
110
+
111
+ # Write back
112
+ with open(secrets_file, "w") as f:
113
+ f.writelines(existing_lines)
114
+
115
+ def delete(self, key: str, namespace: str = "titan", scope: ScopeType = "user"):
116
+ """Delete secret from specified scope"""
117
+ if scope == "env":
118
+ env_key = key.upper()
119
+ os.environ.pop(env_key, None)
120
+
121
+ elif scope == "user":
122
+ try:
123
+ keyring.delete_password(namespace, key)
124
+ except Exception:
125
+ pass # Keyring might not be available
126
+
127
+ elif scope == "project":
128
+ secrets_file = self.project_path / ".titan" / "secrets.env"
129
+ if not secrets_file.exists():
130
+ return
131
+
132
+ # Read and filter
133
+ with open(secrets_file, "r") as f:
134
+ lines = f.readlines()
135
+
136
+ key_upper = key.upper()
137
+ filtered = [line for line in lines if not line.startswith(f"{key_upper}=")]
138
+
139
+ # Write back
140
+ with open(secrets_file, "w") as f:
141
+ f.writelines(filtered)
@@ -0,0 +1,22 @@
1
+ # titan_cli/core/workflows/__init__.py
2
+ """
3
+ Workflow management system.
4
+
5
+ Similar to plugins system, but for workflows:
6
+ - WorkflowRegistry: Discover and manage workflows
7
+ - WorkflowSource: Load from multiple sources (project, user, system, plugins)
8
+ """
9
+
10
+ from .workflow_registry import WorkflowRegistry, ParsedWorkflow
11
+ from .workflow_sources import WorkflowInfo
12
+ from .workflow_exceptions import WorkflowNotFoundError, WorkflowExecutionError
13
+ from .project_step_source import ProjectStepSource
14
+
15
+ __all__ = [
16
+ "WorkflowRegistry",
17
+ "WorkflowInfo",
18
+ "ParsedWorkflow",
19
+ "WorkflowNotFoundError",
20
+ "WorkflowExecutionError",
21
+ "ProjectStepSource",
22
+ ]
@@ -0,0 +1,88 @@
1
+ from typing import Any, Dict, List, Literal, Optional, Union
2
+ from pydantic import BaseModel, Field, model_validator
3
+
4
+ class WorkflowStepModel(BaseModel):
5
+ """
6
+ Represents a single step in a workflow.
7
+ """
8
+ id: Optional[str] = Field(None, description="Unique identifier for the step. Auto-generated from name if not provided.")
9
+ name: Optional[str] = Field(None, description="Human-readable name for the step.")
10
+ plugin: Optional[str] = Field(None, description="The plugin providing the step (e.g., 'git', 'github').")
11
+ step: Optional[str] = Field(None, description="The name of the step function within the plugin.")
12
+ command: Optional[str] = Field(None, description="A shell command to execute.")
13
+ workflow: Optional[str] = Field(None, description="The name of another workflow to execute.")
14
+ params: Dict[str, Any] = Field(default_factory=dict, description="Parameters to pass to the step or command.")
15
+ on_error: Literal["fail", "continue"] = Field("fail", description="Action to take if the step fails.")
16
+ use_shell: bool = Field(False, description="If true, execute the command in a shell. WARNING: This can be a security risk if the command uses untrusted input.")
17
+
18
+ # Used only in base workflow definitions to mark injection points for hooks
19
+ hook: Optional[str] = Field(None, description="Marks this step as a hook point for extension.")
20
+
21
+ @model_validator(mode='after')
22
+ def validate_step_type(self):
23
+ """
24
+ Validate that the step has exactly one of: (plugin + step), command, or workflow.
25
+ Also auto-generates id from name if not provided.
26
+ """
27
+ # Auto-generate id from name if not provided
28
+ if not self.id:
29
+ if self.name:
30
+ # Convert name to valid id: lowercase, replace non-alphanumeric with underscore
31
+ import re
32
+ self.id = re.sub(r'[^a-z0-9_]', '_', self.name.lower()).strip('_')
33
+ elif self.hook:
34
+ # For hook-only steps, use hook name as id
35
+ self.id = f"hook_{self.hook}"
36
+ elif self.plugin and self.step:
37
+ # For plugin steps, use plugin_step as id
38
+ self.id = f"{self.plugin}_{self.step}"
39
+ elif self.workflow:
40
+ # For workflow steps, use workflow name as id
41
+ self.id = f"workflow_{self.workflow.replace(':', '_').replace('/', '_')}"
42
+ elif self.command:
43
+ # For command steps, generate generic id
44
+ self.id = "command_step"
45
+ else:
46
+ # Fallback
47
+ self.id = "step"
48
+
49
+ # A step can be just a hook, in which case other fields are not needed.
50
+ if self.hook:
51
+ return self
52
+
53
+ has_plugin_step = self.plugin is not None and self.step is not None
54
+ has_command = self.command is not None
55
+ has_workflow = self.workflow is not None
56
+
57
+ provided_actions = sum([has_plugin_step, has_command, has_workflow])
58
+
59
+ if provided_actions > 1:
60
+ raise ValueError(f"Step '{self.id}' can only have one action type, but found multiple: "
61
+ f"{'plugin/step, ' if has_plugin_step else ''}"
62
+ f"{'command, ' if has_command else ''}"
63
+ f"{'workflow' if has_workflow else ''}".strip(', '))
64
+
65
+ if provided_actions == 0 and not self.hook:
66
+ raise ValueError(f"Step '{self.id}' must define an action: either (plugin and step), a command, a workflow, or a hook.")
67
+
68
+ return self
69
+
70
+
71
+ class WorkflowConfigModel(BaseModel):
72
+ """
73
+ Represents the overall configuration of a workflow.
74
+ """
75
+ name: str = Field(..., description="The name of the workflow.")
76
+ description: Optional[str] = Field(None, description="A description of what the workflow does.")
77
+ source: Optional[str] = Field(None, description="Where the workflow is defined (e.g., 'plugin:github').")
78
+ extends: Optional[str] = Field(None, description="The base workflow this workflow extends.")
79
+
80
+ params: Dict[str, Any] = Field(default_factory=dict, description="Workflow-level parameters that can be overridden.")
81
+
82
+ # For base workflows: list of hook names (e.g., ["before_commit"])
83
+ # For extending workflows: dict of hook_name -> list of steps to inject
84
+ # This will be handled during loading/merging by the WorkflowLoader,
85
+ # so we define it broadly here and refine during processing.
86
+ hooks: Optional[Union[List[str], Dict[str, List[WorkflowStepModel]]]] = Field(None, description="Hook definitions or steps to inject into hooks.")
87
+
88
+ steps: List[WorkflowStepModel] = Field(default_factory=list, description="The sequence of steps in the workflow.")
@@ -0,0 +1,86 @@
1
+ import importlib.util
2
+ from pathlib import Path
3
+ from typing import Callable, Dict, Any, Optional, List
4
+ from dataclasses import dataclass
5
+
6
+ from titan_cli.engine.context import WorkflowContext
7
+ from titan_cli.engine.results import WorkflowResult
8
+
9
+ # Define a type alias for a Step Function
10
+ StepFunction = Callable[[WorkflowContext, Dict[str, Any]], WorkflowResult]
11
+
12
+ @dataclass
13
+ class StepInfo:
14
+ """
15
+ Metadata for a discovered project step.
16
+ """
17
+ name: str
18
+ path: Path
19
+
20
+ class ProjectStepSource:
21
+ """
22
+ Discovers and loads Python step functions from a project's .titan/steps/ directory.
23
+ """
24
+ def __init__(self, project_root: Path):
25
+ self._project_root = project_root
26
+ self._steps_dir = self._project_root / ".titan" / "steps"
27
+ self._step_info_cache: Optional[List[StepInfo]] = None
28
+ self._step_function_cache: Dict[str, StepFunction] = {}
29
+
30
+ EXCLUDED_FILES = {"__init__.py", "__pycache__"}
31
+
32
+ def discover(self) -> List[StepInfo]:
33
+ """
34
+ Discovers all available step files in the project's .titan/steps directory.
35
+ """
36
+ if self._step_info_cache is not None:
37
+ return self._step_info_cache
38
+
39
+ if not self._steps_dir.is_dir():
40
+ self._step_info_cache = []
41
+ return []
42
+
43
+ discovered = []
44
+ for step_file in self._steps_dir.glob("*.py"):
45
+ if step_file.name not in self.EXCLUDED_FILES:
46
+ step_name = step_file.stem
47
+ discovered.append(StepInfo(name=step_name, path=step_file))
48
+
49
+ self._step_info_cache = discovered
50
+ return discovered
51
+
52
+ def get_step(self, step_name: str) -> Optional[StepFunction]:
53
+ """
54
+ Retrieves a step function by name, loading it from its file if necessary.
55
+ """
56
+ if step_name in self._step_function_cache:
57
+ return self._step_function_cache[step_name]
58
+
59
+ # Find the step info from the discovered list
60
+ discovered_steps = self.discover()
61
+ step_info = next((s for s in discovered_steps if s.name == step_name), None)
62
+
63
+ if not step_info:
64
+ return None
65
+
66
+ try:
67
+ spec = importlib.util.spec_from_file_location(step_name, step_info.path)
68
+ if spec and spec.loader:
69
+ module = importlib.util.module_from_spec(spec)
70
+ spec.loader.exec_module(module)
71
+
72
+ # Convention: the step function has the same name as the file
73
+ step_func = getattr(module, step_name, None)
74
+ if callable(step_func):
75
+ self._step_function_cache[step_name] = step_func
76
+ return step_func
77
+ else:
78
+ # Optional: Log a warning if a file exists but the function doesn't
79
+ pass
80
+
81
+ except Exception as e:
82
+ # Optional: Log a more detailed error
83
+ print(f"Error loading project step '{step_name}': {e}")
84
+
85
+ return None
86
+
@@ -0,0 +1,17 @@
1
+ from titan_cli.core.errors import TitanError
2
+
3
+ class WorkflowError(TitanError):
4
+ """Base exception for workflow-related errors."""
5
+ pass
6
+
7
+ class WorkflowNotFoundError(WorkflowError):
8
+ """Raised when a workflow or its base cannot be found."""
9
+ pass
10
+
11
+ class WorkflowValidationError(WorkflowError):
12
+ """Raised when a workflow configuration fails Pydantic validation."""
13
+ pass
14
+
15
+ class WorkflowExecutionError(WorkflowError):
16
+ """Raised when a workflow execution fails."""
17
+ pass
@@ -0,0 +1,137 @@
1
+ """
2
+ Workflow Filter Service
3
+
4
+ Service for filtering and grouping workflows by plugin.
5
+ """
6
+ from typing import Dict, List, Set
7
+
8
+ from .workflow_sources import WorkflowInfo
9
+
10
+
11
+ class WorkflowFilterService:
12
+ """Service for filtering and grouping workflows."""
13
+
14
+ @staticmethod
15
+ def detect_plugin_name(wf_info: WorkflowInfo) -> str:
16
+ """
17
+ Detect the actual plugin name for a workflow.
18
+
19
+ This method intelligently detects which plugin a workflow belongs to,
20
+ even for project/user workflows that use plugin steps.
21
+
22
+ Args:
23
+ wf_info: WorkflowInfo object to analyze
24
+
25
+ Returns:
26
+ Plugin name in capitalized form (e.g., "Github", "Jira", "Custom")
27
+
28
+ Examples:
29
+ Plugin workflow: "plugin:github" -> "Github"
30
+ Project workflow using GitHub steps -> "Github"
31
+ Project workflow using Jira steps -> "Jira"
32
+ Project workflow with no plugins -> "Custom"
33
+ """
34
+ # If it's already from a plugin, extract the name
35
+ if wf_info.source.startswith("plugin:"):
36
+ plugin_name = wf_info.source.split(":", 1)[1]
37
+ return plugin_name.capitalize()
38
+
39
+ # For project/user workflows, check which plugin they use
40
+ if wf_info.source in ["project", "user"]:
41
+ if wf_info.required_plugins:
42
+ # Use the first required plugin (most workflows use only one)
43
+ primary_plugin = sorted(wf_info.required_plugins)[0]
44
+ return primary_plugin.capitalize()
45
+ else:
46
+ # No plugin dependencies, it's a custom workflow
47
+ return "Custom"
48
+
49
+ # Fallback for other sources
50
+ return wf_info.source.capitalize()
51
+
52
+ @staticmethod
53
+ def group_by_plugin(workflows: List[WorkflowInfo]) -> Dict[str, List[WorkflowInfo]]:
54
+ """
55
+ Group workflows by their associated plugin.
56
+
57
+ Analyzes each workflow to determine its plugin and creates a mapping
58
+ from plugin names to lists of workflows.
59
+
60
+ Args:
61
+ workflows: List of WorkflowInfo objects to group
62
+
63
+ Returns:
64
+ Dictionary mapping plugin names to lists of workflows
65
+
66
+ Example:
67
+ {
68
+ "Github": [workflow1, workflow2],
69
+ "Jira": [workflow3],
70
+ "Custom": [workflow4]
71
+ }
72
+ """
73
+ plugin_map: Dict[str, List[WorkflowInfo]] = {}
74
+
75
+ for wf_info in workflows:
76
+ plugin_name = WorkflowFilterService.detect_plugin_name(wf_info)
77
+
78
+ if plugin_name not in plugin_map:
79
+ plugin_map[plugin_name] = []
80
+ plugin_map[plugin_name].append(wf_info)
81
+
82
+ return plugin_map
83
+
84
+ @staticmethod
85
+ def get_unique_plugin_names(workflows: List[WorkflowInfo]) -> Set[str]:
86
+ """
87
+ Get all unique plugin names from a list of workflows.
88
+
89
+ Args:
90
+ workflows: List of WorkflowInfo objects
91
+
92
+ Returns:
93
+ Set of unique plugin names
94
+ """
95
+ plugin_names = set()
96
+ for wf_info in workflows:
97
+ plugin_name = WorkflowFilterService.detect_plugin_name(wf_info)
98
+ plugin_names.add(plugin_name)
99
+ return plugin_names
100
+
101
+ @staticmethod
102
+ def filter_by_plugin(workflows: List[WorkflowInfo], plugin_name: str) -> List[WorkflowInfo]:
103
+ """
104
+ Filter workflows by plugin name.
105
+
106
+ Args:
107
+ workflows: List of WorkflowInfo objects to filter
108
+ plugin_name: Plugin name to filter by (e.g., "Github")
109
+
110
+ Returns:
111
+ List of workflows that belong to the specified plugin
112
+ """
113
+ return [
114
+ wf for wf in workflows
115
+ if WorkflowFilterService.detect_plugin_name(wf) == plugin_name
116
+ ]
117
+
118
+ @staticmethod
119
+ def remove_duplicates(workflows: List[WorkflowInfo]) -> List[WorkflowInfo]:
120
+ """
121
+ Remove duplicate workflows by name, keeping first occurrence.
122
+
123
+ Args:
124
+ workflows: List of WorkflowInfo objects that may contain duplicates
125
+
126
+ Returns:
127
+ List of unique workflows (by name)
128
+ """
129
+ seen = set()
130
+ unique_workflows = []
131
+
132
+ for wf in workflows:
133
+ if wf.name not in seen:
134
+ seen.add(wf.name)
135
+ unique_workflows.append(wf)
136
+
137
+ return unique_workflows