mcp-souschef 2.8.0__py3-none-any.whl → 3.2.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.
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/METADATA +159 -384
- mcp_souschef-3.2.0.dist-info/RECORD +47 -0
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/WHEEL +1 -1
- souschef/__init__.py +31 -7
- souschef/assessment.py +1451 -105
- souschef/ci/common.py +126 -0
- souschef/ci/github_actions.py +3 -92
- souschef/ci/gitlab_ci.py +2 -52
- souschef/ci/jenkins_pipeline.py +2 -59
- souschef/cli.py +149 -16
- souschef/converters/playbook.py +378 -138
- souschef/converters/resource.py +12 -11
- souschef/converters/template.py +177 -0
- souschef/core/__init__.py +6 -1
- souschef/core/metrics.py +313 -0
- souschef/core/path_utils.py +233 -19
- souschef/core/validation.py +53 -0
- souschef/deployment.py +71 -12
- souschef/generators/__init__.py +13 -0
- souschef/generators/repo.py +695 -0
- souschef/parsers/attributes.py +1 -1
- souschef/parsers/habitat.py +1 -1
- souschef/parsers/inspec.py +25 -2
- souschef/parsers/metadata.py +5 -3
- souschef/parsers/recipe.py +1 -1
- souschef/parsers/resource.py +1 -1
- souschef/parsers/template.py +1 -1
- souschef/server.py +1039 -121
- souschef/ui/app.py +486 -374
- souschef/ui/pages/ai_settings.py +74 -8
- souschef/ui/pages/cookbook_analysis.py +3216 -373
- souschef/ui/pages/validation_reports.py +274 -0
- mcp_souschef-2.8.0.dist-info/RECORD +0 -42
- souschef/converters/cookbook_specific.py.backup +0 -109
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/licenses/LICENSE +0 -0
souschef/converters/resource.py
CHANGED
|
@@ -30,20 +30,14 @@ def _parse_properties(properties_str: str) -> dict[str, Any]:
|
|
|
30
30
|
if not properties_str:
|
|
31
31
|
return {}
|
|
32
32
|
try:
|
|
33
|
-
#
|
|
33
|
+
# Use ast.literal_eval for safe parsing of Python literals
|
|
34
34
|
result = ast.literal_eval(properties_str)
|
|
35
35
|
if isinstance(result, dict):
|
|
36
36
|
return result
|
|
37
37
|
return {}
|
|
38
38
|
except (ValueError, SyntaxError):
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
result = eval(properties_str) # noqa: S307
|
|
42
|
-
if isinstance(result, dict):
|
|
43
|
-
return result
|
|
44
|
-
return {}
|
|
45
|
-
except Exception:
|
|
46
|
-
return {}
|
|
39
|
+
# If parsing fails, return empty dict rather than using unsafe eval
|
|
40
|
+
return {}
|
|
47
41
|
|
|
48
42
|
|
|
49
43
|
def _normalize_template_value(value: Any) -> Any:
|
|
@@ -245,13 +239,17 @@ def _get_include_recipe_params(
|
|
|
245
239
|
Build parameters for include_recipe resources.
|
|
246
240
|
|
|
247
241
|
Uses cookbook-specific configurations when available.
|
|
242
|
+
Falls back to include_role for unknown cookbooks.
|
|
248
243
|
"""
|
|
249
244
|
cookbook_config = get_cookbook_package_config(resource_name)
|
|
250
245
|
if cookbook_config:
|
|
251
246
|
# Return a copy to prevent callers from mutating the shared mapping.
|
|
252
247
|
return dict(cookbook_config["params"])
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
|
|
249
|
+
# For unknown cookbooks, use include_role with the cookbook name
|
|
250
|
+
# Extract cookbook name from "cookbook::recipe" format
|
|
251
|
+
cookbook_name = resource_name.split("::")[0]
|
|
252
|
+
return {"name": cookbook_name}
|
|
255
253
|
|
|
256
254
|
|
|
257
255
|
def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
|
|
@@ -303,6 +301,9 @@ def _convert_chef_resource_to_ansible(
|
|
|
303
301
|
cookbook_config = get_cookbook_package_config(resource_name)
|
|
304
302
|
if cookbook_config:
|
|
305
303
|
ansible_module = cookbook_config["module"]
|
|
304
|
+
else:
|
|
305
|
+
# For include_recipe without specific mapping, use include_role
|
|
306
|
+
ansible_module = "ansible.builtin.include_role"
|
|
306
307
|
|
|
307
308
|
# Handle unknown resource types
|
|
308
309
|
if ansible_module is None:
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Chef ERB template to Jinja2 converter."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from souschef.parsers.template import (
|
|
6
|
+
_convert_erb_to_jinja2,
|
|
7
|
+
_extract_template_variables,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def convert_template_file(erb_path: str) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
Convert an ERB template file to Jinja2 format.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
erb_path: Path to the ERB template file.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary containing:
|
|
20
|
+
- success: bool, whether conversion succeeded
|
|
21
|
+
- original_file: str, path to original ERB file
|
|
22
|
+
- jinja2_file: str, suggested path for .j2 file
|
|
23
|
+
- jinja2_content: str, converted Jinja2 template content
|
|
24
|
+
- variables: list, variables found in template
|
|
25
|
+
- error: str (optional), error message if conversion failed
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
file_path = Path(erb_path).resolve()
|
|
30
|
+
|
|
31
|
+
if not file_path.exists():
|
|
32
|
+
return {
|
|
33
|
+
"success": False,
|
|
34
|
+
"error": f"File not found: {erb_path}",
|
|
35
|
+
"original_file": erb_path,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if not file_path.is_file():
|
|
39
|
+
return {
|
|
40
|
+
"success": False,
|
|
41
|
+
"error": f"Path is not a file: {erb_path}",
|
|
42
|
+
"original_file": erb_path,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Read ERB template
|
|
46
|
+
try:
|
|
47
|
+
content = file_path.read_text(encoding="utf-8")
|
|
48
|
+
except UnicodeDecodeError:
|
|
49
|
+
return {
|
|
50
|
+
"success": False,
|
|
51
|
+
"error": f"Unable to decode {erb_path} as UTF-8 text",
|
|
52
|
+
"original_file": str(file_path),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Extract variables
|
|
56
|
+
variables = _extract_template_variables(content)
|
|
57
|
+
|
|
58
|
+
# Convert ERB to Jinja2
|
|
59
|
+
jinja2_content = _convert_erb_to_jinja2(content)
|
|
60
|
+
|
|
61
|
+
# Determine output file name
|
|
62
|
+
jinja2_file = str(file_path).replace(".erb", ".j2")
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"success": True,
|
|
66
|
+
"original_file": str(file_path),
|
|
67
|
+
"jinja2_file": jinja2_file,
|
|
68
|
+
"jinja2_content": jinja2_content,
|
|
69
|
+
"variables": sorted(variables),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return {
|
|
74
|
+
"success": False,
|
|
75
|
+
"error": f"Conversion failed: {e}",
|
|
76
|
+
"original_file": erb_path,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def convert_cookbook_templates(cookbook_path: str) -> dict:
|
|
81
|
+
"""
|
|
82
|
+
Convert all ERB templates in a cookbook to Jinja2.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
cookbook_path: Path to the cookbook directory.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dictionary containing:
|
|
89
|
+
- success: bool, whether all conversions succeeded
|
|
90
|
+
- templates_converted: int, number of templates successfully converted
|
|
91
|
+
- templates_failed: int, number of templates that failed conversion
|
|
92
|
+
- results: list of dict, individual template conversion results
|
|
93
|
+
- error: str (optional), error message if cookbook not found
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
cookbook_dir = Path(cookbook_path).resolve()
|
|
98
|
+
|
|
99
|
+
if not cookbook_dir.exists():
|
|
100
|
+
return {
|
|
101
|
+
"success": False,
|
|
102
|
+
"error": f"Cookbook directory not found: {cookbook_path}",
|
|
103
|
+
"templates_converted": 0,
|
|
104
|
+
"templates_failed": 0,
|
|
105
|
+
"results": [],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Find all .erb files in the cookbook
|
|
109
|
+
erb_files = list(cookbook_dir.glob("**/*.erb"))
|
|
110
|
+
|
|
111
|
+
if not erb_files:
|
|
112
|
+
return {
|
|
113
|
+
"success": True,
|
|
114
|
+
"templates_converted": 0,
|
|
115
|
+
"templates_failed": 0,
|
|
116
|
+
"results": [],
|
|
117
|
+
"message": "No ERB templates found in cookbook",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
results = []
|
|
121
|
+
templates_converted = 0
|
|
122
|
+
templates_failed = 0
|
|
123
|
+
|
|
124
|
+
for erb_file in erb_files:
|
|
125
|
+
result = convert_template_file(str(erb_file))
|
|
126
|
+
results.append(result)
|
|
127
|
+
|
|
128
|
+
if result["success"]:
|
|
129
|
+
templates_converted += 1
|
|
130
|
+
else:
|
|
131
|
+
templates_failed += 1
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"success": templates_failed == 0,
|
|
135
|
+
"templates_converted": templates_converted,
|
|
136
|
+
"templates_failed": templates_failed,
|
|
137
|
+
"results": results,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
return {
|
|
142
|
+
"success": False,
|
|
143
|
+
"error": f"Failed to convert cookbook templates: {e}",
|
|
144
|
+
"templates_converted": 0,
|
|
145
|
+
"templates_failed": 0,
|
|
146
|
+
"results": [],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def convert_template_with_ai(erb_path: str, ai_service=None) -> dict:
|
|
151
|
+
"""
|
|
152
|
+
Convert an ERB template to Jinja2 using AI assistance for complex conversions.
|
|
153
|
+
|
|
154
|
+
This function first attempts rule-based conversion, then optionally uses AI
|
|
155
|
+
for validation or complex Ruby logic that can't be automatically converted.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
erb_path: Path to the ERB template file.
|
|
159
|
+
ai_service: Optional AI service instance for complex conversions.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dictionary with conversion results (same format as convert_template_file).
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
# Start with rule-based conversion
|
|
166
|
+
result = convert_template_file(erb_path)
|
|
167
|
+
|
|
168
|
+
# Add conversion method metadata
|
|
169
|
+
result["conversion_method"] = "rule-based"
|
|
170
|
+
|
|
171
|
+
# Future enhancement: Use AI service to validate/improve complex conversions
|
|
172
|
+
if ai_service is not None:
|
|
173
|
+
# AI validation/improvement logic deferred to future enhancement
|
|
174
|
+
# when AI integration becomes more critical to the template conversion process
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return result
|
souschef/core/__init__.py
CHANGED
|
@@ -50,7 +50,11 @@ from souschef.core.errors import (
|
|
|
50
50
|
validate_directory_exists,
|
|
51
51
|
validate_file_exists,
|
|
52
52
|
)
|
|
53
|
-
from souschef.core.path_utils import
|
|
53
|
+
from souschef.core.path_utils import (
|
|
54
|
+
_ensure_within_base_path,
|
|
55
|
+
_normalize_path,
|
|
56
|
+
_safe_join,
|
|
57
|
+
)
|
|
54
58
|
from souschef.core.ruby_utils import _normalize_ruby_value
|
|
55
59
|
from souschef.core.validation import (
|
|
56
60
|
ValidationCategory,
|
|
@@ -63,6 +67,7 @@ __all__ = [
|
|
|
63
67
|
"_normalize_path",
|
|
64
68
|
"_normalize_ruby_value",
|
|
65
69
|
"_safe_join",
|
|
70
|
+
"_ensure_within_base_path",
|
|
66
71
|
"ValidationCategory",
|
|
67
72
|
"ValidationEngine",
|
|
68
73
|
"ValidationLevel",
|
souschef/core/metrics.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Centralized metrics and effort calculation module for consistent time estimations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ComplexityLevel",
|
|
9
|
+
"EffortMetrics",
|
|
10
|
+
"convert_days_to_hours",
|
|
11
|
+
"convert_days_to_weeks",
|
|
12
|
+
"convert_hours_to_days",
|
|
13
|
+
"estimate_effort_for_complexity",
|
|
14
|
+
"get_team_recommendation",
|
|
15
|
+
"get_timeline_weeks",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ComplexityLevel(str, Enum):
|
|
20
|
+
"""Standard complexity levels used across all components."""
|
|
21
|
+
|
|
22
|
+
LOW = "low"
|
|
23
|
+
MEDIUM = "medium"
|
|
24
|
+
HIGH = "high"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class EffortMetrics:
|
|
29
|
+
"""
|
|
30
|
+
Centralized container for all effort estimates.
|
|
31
|
+
|
|
32
|
+
Provides consistent representations across different formats:
|
|
33
|
+
- Base unit: person-days (with decimal precision)
|
|
34
|
+
- Derived: hours, weeks with consistent conversion factors
|
|
35
|
+
- Ranges: For display purposes, converting days to week ranges
|
|
36
|
+
|
|
37
|
+
Ensures all components (migration planning, dependency mapping,
|
|
38
|
+
validation reports) use the same underlying numbers.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
estimated_days: float
|
|
42
|
+
"""Base unit: person-days (e.g., 2.5, 5.0, 10.0)"""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def estimated_hours(self) -> float:
|
|
46
|
+
"""Convert days to hours using standard 8-hour workday."""
|
|
47
|
+
return self.estimated_days * 8
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def estimated_weeks_low(self) -> int:
|
|
51
|
+
"""Conservative estimate: assumes optimal parallelization."""
|
|
52
|
+
return max(1, int(self.estimated_days / 7))
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def estimated_weeks_high(self) -> int:
|
|
56
|
+
"""Realistic estimate: assumes sequential/limited parallelization."""
|
|
57
|
+
return max(1, int(self.estimated_days / 3.5))
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def estimated_weeks_range(self) -> str:
|
|
61
|
+
"""Human-readable week range (e.g., '2-4 weeks')."""
|
|
62
|
+
low = self.estimated_weeks_low
|
|
63
|
+
high = self.estimated_weeks_high
|
|
64
|
+
if low == high:
|
|
65
|
+
return f"{low} week{'s' if low != 1 else ''}"
|
|
66
|
+
return f"{low}-{high} weeks"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def estimated_days_formatted(self) -> str:
|
|
70
|
+
"""Formatted days with appropriate precision."""
|
|
71
|
+
if self.estimated_days == int(self.estimated_days):
|
|
72
|
+
return f"{int(self.estimated_days)} days"
|
|
73
|
+
return f"{self.estimated_days:.1f} days"
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
"""Return a string representation of effort metrics."""
|
|
77
|
+
return f"{self.estimated_days_formatted} ({self.estimated_weeks_range})"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TeamRecommendation(NamedTuple):
|
|
81
|
+
"""Team composition and timeline recommendation."""
|
|
82
|
+
|
|
83
|
+
team_size: str
|
|
84
|
+
"""e.g., '1 developer + 1 reviewer'"""
|
|
85
|
+
|
|
86
|
+
timeline_weeks: int
|
|
87
|
+
"""Recommended timeline in weeks"""
|
|
88
|
+
|
|
89
|
+
description: str
|
|
90
|
+
"""Human-readable description of the recommendation"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Conversion constants - Single source of truth
|
|
94
|
+
HOURS_PER_WORKDAY = 8
|
|
95
|
+
DAYS_PER_WEEK = 7
|
|
96
|
+
|
|
97
|
+
# Complexity thresholds for automatic categorization
|
|
98
|
+
COMPLEXITY_THRESHOLD_LOW = 30
|
|
99
|
+
COMPLEXITY_THRESHOLD_HIGH = 70
|
|
100
|
+
|
|
101
|
+
# Effort multiplier per resource (base 1 resource = baseline effort)
|
|
102
|
+
EFFORT_MULTIPLIER_PER_RESOURCE = 0.125 # 0.125 days = 1 hour per resource
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def convert_days_to_hours(days: float) -> float:
|
|
106
|
+
"""Convert person-days to hours using standard 8-hour workday."""
|
|
107
|
+
return days * HOURS_PER_WORKDAY
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def convert_hours_to_days(hours: float) -> float:
|
|
111
|
+
"""Convert hours to person-days using standard 8-hour workday."""
|
|
112
|
+
return hours / HOURS_PER_WORKDAY
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def convert_days_to_weeks(days: float, conservative: bool = False) -> int:
|
|
116
|
+
"""
|
|
117
|
+
Convert days to weeks estimate.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
days: Number of person-days
|
|
121
|
+
conservative: If True, use realistic estimate (1 engineer, limited
|
|
122
|
+
parallelization). If False, use optimistic estimate (full
|
|
123
|
+
parallelization)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Number of weeks (integer)
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
weeks = days / 3.5 if conservative else days / DAYS_PER_WEEK
|
|
130
|
+
return max(1, int(weeks))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def estimate_effort_for_complexity(
|
|
134
|
+
complexity_score: float, resource_count: int = 1
|
|
135
|
+
) -> EffortMetrics:
|
|
136
|
+
"""
|
|
137
|
+
Estimate effort based on complexity score and resource count.
|
|
138
|
+
|
|
139
|
+
Provides consistent effort estimation across all components.
|
|
140
|
+
|
|
141
|
+
Formula:
|
|
142
|
+
- Base effort: resource_count * 0.125 days per recipe/resource
|
|
143
|
+
- Complexity multiplier: 1.0 + (complexity_score / 100)
|
|
144
|
+
- Final effort: base_effort * complexity_multiplier
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
complexity_score: Score from 0-100 (0=simple, 100=complex)
|
|
148
|
+
resource_count: Number of resources to migrate (recipes, templates, etc.)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
EffortMetrics object with all representations
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
base_effort = resource_count * EFFORT_MULTIPLIER_PER_RESOURCE
|
|
155
|
+
complexity_multiplier = 1.0 + (complexity_score / 100)
|
|
156
|
+
estimated_days = base_effort * complexity_multiplier
|
|
157
|
+
|
|
158
|
+
return EffortMetrics(estimated_days=round(estimated_days, 1))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def categorize_complexity(score: float) -> ComplexityLevel:
|
|
162
|
+
"""
|
|
163
|
+
Categorize complexity score into standard levels.
|
|
164
|
+
|
|
165
|
+
Consistent thresholds across all components:
|
|
166
|
+
- Low: 0-29
|
|
167
|
+
- Medium: 30-69
|
|
168
|
+
- High: 70-100
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
score: Complexity score from 0-100
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
ComplexityLevel enum value
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
if score < COMPLEXITY_THRESHOLD_LOW:
|
|
178
|
+
return ComplexityLevel.LOW
|
|
179
|
+
elif score < COMPLEXITY_THRESHOLD_HIGH:
|
|
180
|
+
return ComplexityLevel.MEDIUM
|
|
181
|
+
else:
|
|
182
|
+
return ComplexityLevel.HIGH
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_team_recommendation(total_effort_days: float) -> TeamRecommendation:
|
|
186
|
+
"""
|
|
187
|
+
Get team composition and timeline recommendation based on total effort.
|
|
188
|
+
|
|
189
|
+
Consistent recommendations across all components.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
total_effort_days: Total person-days of effort
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
TeamRecommendation with team size and timeline
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
if total_effort_days < 20:
|
|
199
|
+
return TeamRecommendation(
|
|
200
|
+
team_size="1 developer + 1 reviewer",
|
|
201
|
+
timeline_weeks=4,
|
|
202
|
+
description="Single developer with oversight",
|
|
203
|
+
)
|
|
204
|
+
elif total_effort_days < 50:
|
|
205
|
+
return TeamRecommendation(
|
|
206
|
+
team_size="2 developers + 1 senior reviewer",
|
|
207
|
+
timeline_weeks=6,
|
|
208
|
+
description="Small dedicated team",
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
return TeamRecommendation(
|
|
212
|
+
team_size="3-4 developers + 1 tech lead + 1 architect",
|
|
213
|
+
timeline_weeks=10,
|
|
214
|
+
description="Large dedicated migration team",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_timeline_weeks(total_effort_days: float, strategy: str = "phased") -> int:
|
|
219
|
+
"""
|
|
220
|
+
Calculate recommended timeline in weeks based on effort and strategy.
|
|
221
|
+
|
|
222
|
+
Consistent timeline calculation across planning, dependency mapping, and reports.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
total_effort_days: Total person-days estimated
|
|
226
|
+
strategy: Migration strategy ('phased', 'big_bang', 'parallel')
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Recommended timeline in weeks
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
# Base calculation: distribute effort across team capacity
|
|
233
|
+
# Assume 3-5 person-days of output per week with normal team capacity
|
|
234
|
+
base_weeks = max(2, int(total_effort_days / 4.5))
|
|
235
|
+
|
|
236
|
+
# Apply strategy adjustments
|
|
237
|
+
if strategy == "phased":
|
|
238
|
+
# Phased adds overhead for testing between phases
|
|
239
|
+
return int(base_weeks * 1.1)
|
|
240
|
+
elif strategy == "big_bang":
|
|
241
|
+
# Big bang is faster but riskier
|
|
242
|
+
return int(base_weeks * 0.9)
|
|
243
|
+
else: # parallel
|
|
244
|
+
# Parallel has some overhead for coordination
|
|
245
|
+
return int(base_weeks * 1.05)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def validate_metrics_consistency(
|
|
249
|
+
days: float, weeks: str, hours: float, complexity: str
|
|
250
|
+
) -> tuple[bool, list[str]]:
|
|
251
|
+
"""
|
|
252
|
+
Validate that different metric representations are consistent.
|
|
253
|
+
|
|
254
|
+
Used for validation reports to catch contradictions.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
days: Days estimate
|
|
258
|
+
weeks: Weeks range string (e.g., "2-4 weeks")
|
|
259
|
+
hours: Hours estimate
|
|
260
|
+
complexity: Complexity level string
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Tuple of (is_valid, list_of_errors)
|
|
264
|
+
|
|
265
|
+
"""
|
|
266
|
+
errors = []
|
|
267
|
+
|
|
268
|
+
# Check hours consistency
|
|
269
|
+
expected_hours = days * 8
|
|
270
|
+
if abs(hours - expected_hours) > 1.0: # Allow 1 hour tolerance
|
|
271
|
+
errors.append(
|
|
272
|
+
f"Hours mismatch: {hours:.1f} hours but {days} days = "
|
|
273
|
+
f"{expected_hours:.1f} hours"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Check weeks consistency (loose check due to range)
|
|
277
|
+
# Valid formats: "1 week", "1-2 weeks"
|
|
278
|
+
if "week" not in weeks.lower():
|
|
279
|
+
errors.append(f"Invalid weeks format: {weeks}")
|
|
280
|
+
elif "-" in weeks:
|
|
281
|
+
# Range format: "X-Y weeks"
|
|
282
|
+
try:
|
|
283
|
+
parts = weeks.replace(" weeks", "").replace(" week", "").split("-")
|
|
284
|
+
week_min = int(parts[0].strip())
|
|
285
|
+
week_max = int(parts[1].strip())
|
|
286
|
+
expected_weeks = int(days / 3.5) # Conservative estimate
|
|
287
|
+
|
|
288
|
+
if not (week_min <= expected_weeks <= week_max + 1):
|
|
289
|
+
errors.append(
|
|
290
|
+
f"Weeks mismatch: {weeks} but {days} days should be "
|
|
291
|
+
f"approximately {expected_weeks} weeks"
|
|
292
|
+
)
|
|
293
|
+
except (ValueError, IndexError):
|
|
294
|
+
errors.append(f"Invalid weeks format: {weeks}")
|
|
295
|
+
else:
|
|
296
|
+
# Single week format: "X week" or "X weeks"
|
|
297
|
+
try:
|
|
298
|
+
num = int(weeks.replace(" weeks", "").replace(" week", "").strip())
|
|
299
|
+
expected_weeks = int(days / 3.5)
|
|
300
|
+
if num != expected_weeks and abs(num - expected_weeks) > 2:
|
|
301
|
+
errors.append(
|
|
302
|
+
f"Weeks mismatch: {weeks} but {days} days should be "
|
|
303
|
+
f"approximately {expected_weeks} weeks"
|
|
304
|
+
)
|
|
305
|
+
except ValueError:
|
|
306
|
+
errors.append(f"Invalid weeks format: {weeks}")
|
|
307
|
+
|
|
308
|
+
# Check complexity is valid
|
|
309
|
+
valid_complexities = {level.value for level in ComplexityLevel}
|
|
310
|
+
if complexity.lower() not in valid_complexities:
|
|
311
|
+
errors.append(f"Invalid complexity level: {complexity}")
|
|
312
|
+
|
|
313
|
+
return len(errors) == 0, errors
|