doit-toolkit-cli 0.1.9__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.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +49 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1121 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +368 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.9.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.9.dist-info/RECORD +134 -0
- doit_toolkit_cli-0.1.9.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.9.dist-info/entry_points.txt +2 -0
- doit_toolkit_cli-0.1.9.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Service to read and scan doit command templates."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..models.sync_models import CommandTemplate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TemplateReader:
|
|
10
|
+
"""Reads command templates from bundled or project templates directory.
|
|
11
|
+
|
|
12
|
+
Supports three template locations (checked in order):
|
|
13
|
+
1. CLI repo development: src/doit_cli/templates/commands/
|
|
14
|
+
2. Installed package: via importlib.resources (doit_cli.templates.commands)
|
|
15
|
+
3. Project templates: .doit/templates/commands/ (for end-user projects)
|
|
16
|
+
|
|
17
|
+
When running from an installed package, bundled templates are used.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
BUNDLED_TEMPLATES_DIR = "src/doit_cli/templates/commands"
|
|
21
|
+
PROJECT_TEMPLATES_DIR = ".doit/templates/commands"
|
|
22
|
+
COMMAND_PATTERN = "doit.*.md"
|
|
23
|
+
|
|
24
|
+
def __init__(self, project_root: Path | None = None):
|
|
25
|
+
"""Initialize the template reader.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
project_root: Root directory of the project. Defaults to current directory.
|
|
29
|
+
"""
|
|
30
|
+
self.project_root = project_root or Path.cwd()
|
|
31
|
+
self.templates_dir = self._resolve_templates_directory()
|
|
32
|
+
|
|
33
|
+
def _resolve_templates_directory(self) -> Path:
|
|
34
|
+
"""Resolve the templates directory.
|
|
35
|
+
|
|
36
|
+
Priority order:
|
|
37
|
+
1. Project templates (.doit/templates/commands/) - allows customization
|
|
38
|
+
2. CLI repo development (src/doit_cli/templates/commands/)
|
|
39
|
+
3. Installed package templates (via importlib.resources)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Path to the templates directory to use.
|
|
43
|
+
"""
|
|
44
|
+
# First, check for project templates (allows customization)
|
|
45
|
+
project_dir = self.project_root / self.PROJECT_TEMPLATES_DIR
|
|
46
|
+
if project_dir.exists() and any(project_dir.glob(self.COMMAND_PATTERN)):
|
|
47
|
+
return project_dir
|
|
48
|
+
|
|
49
|
+
# Second, check for CLI repo development templates
|
|
50
|
+
bundled_dir = self.project_root / self.BUNDLED_TEMPLATES_DIR
|
|
51
|
+
if bundled_dir.exists() and any(bundled_dir.glob(self.COMMAND_PATTERN)):
|
|
52
|
+
return bundled_dir
|
|
53
|
+
|
|
54
|
+
# Third, try to find templates from installed package
|
|
55
|
+
package_templates = self._get_package_templates_directory()
|
|
56
|
+
if package_templates and package_templates.exists():
|
|
57
|
+
if any(package_templates.glob(self.COMMAND_PATTERN)):
|
|
58
|
+
return package_templates
|
|
59
|
+
|
|
60
|
+
# Fall back to project templates path (will be empty/not exist)
|
|
61
|
+
return project_dir
|
|
62
|
+
|
|
63
|
+
def _get_package_templates_directory(self) -> Path | None:
|
|
64
|
+
"""Get the templates directory from the installed package.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path to bundled templates directory, or None if not found.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
# Use importlib.resources to find package templates
|
|
71
|
+
# Get the actual path from the package location
|
|
72
|
+
pkg_files = importlib.resources.files("doit_cli")
|
|
73
|
+
templates_path = pkg_files.joinpath("templates/commands")
|
|
74
|
+
|
|
75
|
+
# Check if it's a real path we can use
|
|
76
|
+
if hasattr(templates_path, "_path"):
|
|
77
|
+
# Direct filesystem path (editable install or source)
|
|
78
|
+
return Path(templates_path._path)
|
|
79
|
+
|
|
80
|
+
# Try to get path via traversable
|
|
81
|
+
# For installed packages, get the actual location
|
|
82
|
+
import doit_cli
|
|
83
|
+
pkg_location = Path(doit_cli.__file__).parent
|
|
84
|
+
templates_dir = pkg_location / "templates" / "commands"
|
|
85
|
+
if templates_dir.exists():
|
|
86
|
+
return templates_dir
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
except (ImportError, TypeError, AttributeError, FileNotFoundError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def get_templates_directory(self) -> Path:
|
|
93
|
+
"""Get the templates directory path."""
|
|
94
|
+
return self.templates_dir
|
|
95
|
+
|
|
96
|
+
def is_using_bundled_templates(self) -> bool:
|
|
97
|
+
"""Check if using bundled templates (CLI repo) vs project templates.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if using bundled templates, False if using project templates.
|
|
101
|
+
"""
|
|
102
|
+
return self.BUNDLED_TEMPLATES_DIR in str(self.templates_dir)
|
|
103
|
+
|
|
104
|
+
def scan_templates(self, filter_name: str | None = None) -> list[CommandTemplate]:
|
|
105
|
+
"""Scan for command templates in the templates directory.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
filter_name: Optional command name to filter by (e.g., "doit.checkin").
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of CommandTemplate objects found.
|
|
112
|
+
"""
|
|
113
|
+
if not self.templates_dir.exists():
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
templates = []
|
|
117
|
+
pattern = self.COMMAND_PATTERN
|
|
118
|
+
|
|
119
|
+
for path in sorted(self.templates_dir.glob(pattern)):
|
|
120
|
+
if not path.is_file():
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Apply name filter if specified
|
|
124
|
+
if filter_name:
|
|
125
|
+
# Allow matching with or without .md extension
|
|
126
|
+
filter_stem = filter_name.replace(".md", "")
|
|
127
|
+
if path.stem != filter_stem:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
template = CommandTemplate.from_path(path)
|
|
132
|
+
templates.append(template)
|
|
133
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
134
|
+
# Log error but continue with other templates
|
|
135
|
+
print(f"Warning: Could not read template {path}: {e}")
|
|
136
|
+
|
|
137
|
+
return templates
|
|
138
|
+
|
|
139
|
+
def get_template(self, name: str) -> CommandTemplate | None:
|
|
140
|
+
"""Get a specific command template by name.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
name: Command name (e.g., "doit.checkin" or "doit.checkin.md").
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
CommandTemplate if found, None otherwise.
|
|
147
|
+
"""
|
|
148
|
+
templates = self.scan_templates(filter_name=name)
|
|
149
|
+
return templates[0] if templates else None
|
|
150
|
+
|
|
151
|
+
def list_template_names(self) -> list[str]:
|
|
152
|
+
"""List all available template names.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
List of template names (e.g., ["doit.checkin", "doit.specit", ...]).
|
|
156
|
+
"""
|
|
157
|
+
templates = self.scan_templates()
|
|
158
|
+
return [t.name for t in templates]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Generator for User Journey flowchart diagrams from user stories."""
|
|
2
|
+
|
|
3
|
+
from ..models.diagram_models import GeneratedDiagram, DiagramType, ParsedUserStory
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UserJourneyGenerator:
|
|
7
|
+
"""Generates Mermaid flowchart diagrams from user stories.
|
|
8
|
+
|
|
9
|
+
Converts ParsedUserStory objects into a flowchart with subgraphs
|
|
10
|
+
per story and nodes for each acceptance scenario.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, direction: str = "LR"):
|
|
14
|
+
"""Initialize generator.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
direction: Flowchart direction (LR, TB, RL, BT)
|
|
18
|
+
"""
|
|
19
|
+
self.direction = direction
|
|
20
|
+
|
|
21
|
+
def generate(self, stories: list[ParsedUserStory]) -> str:
|
|
22
|
+
"""Generate Mermaid flowchart from user stories.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
stories: List of parsed user stories
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Mermaid flowchart syntax
|
|
29
|
+
"""
|
|
30
|
+
if not stories:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
lines = [f"flowchart {self.direction}"]
|
|
34
|
+
|
|
35
|
+
for story in stories:
|
|
36
|
+
story_lines = self._generate_story_subgraph(story)
|
|
37
|
+
lines.extend(story_lines)
|
|
38
|
+
|
|
39
|
+
# Add story connections (story to story flow)
|
|
40
|
+
connection_lines = self._generate_story_connections(stories)
|
|
41
|
+
if connection_lines:
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.extend(connection_lines)
|
|
44
|
+
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
def generate_diagram(self, stories: list[ParsedUserStory]) -> GeneratedDiagram:
|
|
48
|
+
"""Generate a GeneratedDiagram object from user stories.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
stories: List of parsed user stories
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
GeneratedDiagram with content and metadata
|
|
55
|
+
"""
|
|
56
|
+
content = self.generate(stories)
|
|
57
|
+
|
|
58
|
+
# Count nodes
|
|
59
|
+
node_count = sum(
|
|
60
|
+
len(story.scenarios) + 1 for story in stories # +1 for entry node
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return GeneratedDiagram(
|
|
64
|
+
id="user-journey",
|
|
65
|
+
diagram_type=DiagramType.USER_JOURNEY,
|
|
66
|
+
mermaid_content=content,
|
|
67
|
+
is_valid=True,
|
|
68
|
+
node_count=node_count,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _generate_story_subgraph(self, story: ParsedUserStory) -> list[str]:
|
|
72
|
+
"""Generate subgraph for a single user story.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
story: Parsed user story
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of Mermaid syntax lines
|
|
79
|
+
"""
|
|
80
|
+
lines = []
|
|
81
|
+
lines.append(f' subgraph {story.subgraph_id}["{story.subgraph_label}"]')
|
|
82
|
+
|
|
83
|
+
if not story.scenarios:
|
|
84
|
+
# No scenarios - create a single placeholder node
|
|
85
|
+
node_id = f"{story.subgraph_id}_A"
|
|
86
|
+
lines.append(f' {node_id}["{self._escape_label(story.title)}"]')
|
|
87
|
+
else:
|
|
88
|
+
# Create nodes for each scenario
|
|
89
|
+
prev_node_id = None
|
|
90
|
+
for i, scenario in enumerate(story.scenarios):
|
|
91
|
+
letter = chr(ord("A") + i)
|
|
92
|
+
node_id = f"{story.subgraph_id}_{letter}"
|
|
93
|
+
|
|
94
|
+
# Generate node based on scenario
|
|
95
|
+
given_label = self._truncate_label(scenario.given_clause, 50)
|
|
96
|
+
when_label = self._truncate_label(scenario.when_clause, 50)
|
|
97
|
+
then_label = self._truncate_label(scenario.then_clause, 50)
|
|
98
|
+
|
|
99
|
+
# Create nodes for Given, When, Then
|
|
100
|
+
given_node = f"{node_id}_G"
|
|
101
|
+
when_node = f"{node_id}_W"
|
|
102
|
+
then_node = f"{node_id}_T"
|
|
103
|
+
|
|
104
|
+
lines.append(f' {given_node}["{self._escape_label(given_label)}"]')
|
|
105
|
+
lines.append(f' {when_node}{{"{self._escape_label(when_label)}"}}')
|
|
106
|
+
lines.append(f' {then_node}["{self._escape_label(then_label)}"]')
|
|
107
|
+
|
|
108
|
+
# Connect Given -> When -> Then
|
|
109
|
+
lines.append(f" {given_node} --> {when_node}")
|
|
110
|
+
lines.append(f" {when_node} --> {then_node}")
|
|
111
|
+
|
|
112
|
+
# Connect to previous scenario
|
|
113
|
+
if prev_node_id:
|
|
114
|
+
lines.append(f" {prev_node_id} -.-> {given_node}")
|
|
115
|
+
|
|
116
|
+
prev_node_id = then_node
|
|
117
|
+
|
|
118
|
+
lines.append(" end")
|
|
119
|
+
return lines
|
|
120
|
+
|
|
121
|
+
def _generate_story_connections(self, stories: list[ParsedUserStory]) -> list[str]:
|
|
122
|
+
"""Generate connections between story subgraphs.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
stories: List of parsed user stories
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of connection lines
|
|
129
|
+
"""
|
|
130
|
+
if len(stories) <= 1:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
lines = []
|
|
134
|
+
lines.append(" %% Story flow connections")
|
|
135
|
+
|
|
136
|
+
for i in range(len(stories) - 1):
|
|
137
|
+
current_story = stories[i]
|
|
138
|
+
next_story = stories[i + 1]
|
|
139
|
+
|
|
140
|
+
# Connect last node of current story to first node of next
|
|
141
|
+
if current_story.scenarios:
|
|
142
|
+
last_letter = chr(ord("A") + len(current_story.scenarios) - 1)
|
|
143
|
+
current_end = f"{current_story.subgraph_id}_{last_letter}_T"
|
|
144
|
+
else:
|
|
145
|
+
current_end = f"{current_story.subgraph_id}_A"
|
|
146
|
+
|
|
147
|
+
if next_story.scenarios:
|
|
148
|
+
next_start = f"{next_story.subgraph_id}_A_G"
|
|
149
|
+
else:
|
|
150
|
+
next_start = f"{next_story.subgraph_id}_A"
|
|
151
|
+
|
|
152
|
+
# Use dotted line for cross-story connections
|
|
153
|
+
lines.append(f" {current_end} ==> {next_start}")
|
|
154
|
+
|
|
155
|
+
return lines
|
|
156
|
+
|
|
157
|
+
def _truncate_label(self, text: str, max_length: int = 40) -> str:
|
|
158
|
+
"""Truncate label to maximum length.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
text: Original label text
|
|
162
|
+
max_length: Maximum characters
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Truncated text with ellipsis if needed
|
|
166
|
+
"""
|
|
167
|
+
if len(text) <= max_length:
|
|
168
|
+
return text
|
|
169
|
+
return text[: max_length - 3] + "..."
|
|
170
|
+
|
|
171
|
+
def _escape_label(self, text: str) -> str:
|
|
172
|
+
"""Escape special characters for Mermaid labels.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
text: Original label text
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Escaped text safe for Mermaid
|
|
179
|
+
"""
|
|
180
|
+
# Escape quotes
|
|
181
|
+
text = text.replace('"', "'")
|
|
182
|
+
# Escape brackets that could be misinterpreted
|
|
183
|
+
text = text.replace("[", "(")
|
|
184
|
+
text = text.replace("]", ")")
|
|
185
|
+
# Remove newlines
|
|
186
|
+
text = text.replace("\n", " ")
|
|
187
|
+
# Clean up whitespace
|
|
188
|
+
text = " ".join(text.split())
|
|
189
|
+
return text
|
|
190
|
+
|
|
191
|
+
def generate_simple(self, stories: list[ParsedUserStory]) -> str:
|
|
192
|
+
"""Generate a simplified flowchart with one node per story.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
stories: List of parsed user stories
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Simplified Mermaid flowchart syntax
|
|
199
|
+
"""
|
|
200
|
+
if not stories:
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
lines = [f"flowchart {self.direction}"]
|
|
204
|
+
|
|
205
|
+
for i, story in enumerate(stories):
|
|
206
|
+
node_id = story.subgraph_id
|
|
207
|
+
label = f"{story.id}: {self._truncate_label(story.title, 30)}"
|
|
208
|
+
lines.append(f' {node_id}["{self._escape_label(label)}"]')
|
|
209
|
+
|
|
210
|
+
if i > 0:
|
|
211
|
+
prev_id = stories[i - 1].subgraph_id
|
|
212
|
+
lines.append(f" {prev_id} --> {node_id}")
|
|
213
|
+
|
|
214
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Parser for user stories from spec.md files."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models.diagram_models import AcceptanceScenario, ParsedUserStory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UserStoryParser:
|
|
10
|
+
"""Parses user stories from spec.md content.
|
|
11
|
+
|
|
12
|
+
Extracts user story headers, descriptions, and acceptance scenarios
|
|
13
|
+
following the doit spec template format.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Pattern for user story header: ### User Story N - Title (Priority: PN)
|
|
17
|
+
STORY_HEADER_PATTERN = re.compile(
|
|
18
|
+
r"^###\s+User\s+Story\s+(\d+)\s*[-–—]\s*(.+?)\s*\(Priority:\s*(P\d+)\)\s*$",
|
|
19
|
+
re.IGNORECASE | re.MULTILINE,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Pattern for Given/When/Then acceptance scenarios
|
|
23
|
+
# Supports both bold and plain text formats
|
|
24
|
+
SCENARIO_PATTERN = re.compile(
|
|
25
|
+
r"^\d+\.\s+\*?\*?(?:Given)\*?\*?\s+(.+?),\s*\*?\*?(?:When)\*?\*?\s+(.+?),\s*\*?\*?(?:Then)\*?\*?\s+(.+)$",
|
|
26
|
+
re.IGNORECASE | re.MULTILINE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Alternative pattern for bold keywords
|
|
30
|
+
SCENARIO_BOLD_PATTERN = re.compile(
|
|
31
|
+
r"^\d+\.\s+\*\*Given\*\*\s+(.+?),\s*\*\*When\*\*\s+(.+?),\s*\*\*Then\*\*\s+(.+)$",
|
|
32
|
+
re.IGNORECASE | re.MULTILINE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def parse(self, content: str) -> list[ParsedUserStory]:
|
|
36
|
+
"""Parse all user stories from spec content.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
content: Full content of spec.md file
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of ParsedUserStory objects
|
|
43
|
+
"""
|
|
44
|
+
stories = []
|
|
45
|
+
|
|
46
|
+
# Find all story headers and their positions
|
|
47
|
+
header_matches = list(self.STORY_HEADER_PATTERN.finditer(content))
|
|
48
|
+
|
|
49
|
+
if not header_matches:
|
|
50
|
+
return stories
|
|
51
|
+
|
|
52
|
+
# Extract each story's content
|
|
53
|
+
for i, match in enumerate(header_matches):
|
|
54
|
+
story_number = int(match.group(1))
|
|
55
|
+
title = match.group(2).strip()
|
|
56
|
+
priority = match.group(3).strip().upper()
|
|
57
|
+
|
|
58
|
+
# Determine story content boundaries
|
|
59
|
+
start_pos = match.end()
|
|
60
|
+
if i + 1 < len(header_matches):
|
|
61
|
+
end_pos = header_matches[i + 1].start()
|
|
62
|
+
else:
|
|
63
|
+
# Last story - find next major section or end of file
|
|
64
|
+
next_section = self._find_next_section(content, start_pos)
|
|
65
|
+
end_pos = next_section if next_section else len(content)
|
|
66
|
+
|
|
67
|
+
story_content = content[start_pos:end_pos].strip()
|
|
68
|
+
|
|
69
|
+
# Extract description and scenarios
|
|
70
|
+
description = self._extract_description(story_content)
|
|
71
|
+
scenarios = self._extract_scenarios(story_content, story_number)
|
|
72
|
+
|
|
73
|
+
story = ParsedUserStory(
|
|
74
|
+
id=f"US{story_number}",
|
|
75
|
+
story_number=story_number,
|
|
76
|
+
title=title,
|
|
77
|
+
priority=priority,
|
|
78
|
+
description=description,
|
|
79
|
+
scenarios=scenarios,
|
|
80
|
+
raw_text=content[match.start() : end_pos],
|
|
81
|
+
)
|
|
82
|
+
stories.append(story)
|
|
83
|
+
|
|
84
|
+
return stories
|
|
85
|
+
|
|
86
|
+
def parse_single(self, content: str) -> Optional[ParsedUserStory]:
|
|
87
|
+
"""Parse a single user story from content.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
content: Content containing one user story
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
ParsedUserStory if found, None otherwise
|
|
94
|
+
"""
|
|
95
|
+
stories = self.parse(content)
|
|
96
|
+
return stories[0] if stories else None
|
|
97
|
+
|
|
98
|
+
def _extract_description(self, story_content: str) -> str:
|
|
99
|
+
"""Extract user story description from content.
|
|
100
|
+
|
|
101
|
+
The description is typically the narrative before acceptance scenarios.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
story_content: Content of a single user story section
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Description text
|
|
108
|
+
"""
|
|
109
|
+
lines = story_content.split("\n")
|
|
110
|
+
description_lines = []
|
|
111
|
+
|
|
112
|
+
for line in lines:
|
|
113
|
+
stripped = line.strip()
|
|
114
|
+
|
|
115
|
+
# Stop at acceptance scenarios or numbered lists
|
|
116
|
+
if stripped.startswith("1.") or "**Given**" in stripped:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
# Skip empty lines at the start
|
|
120
|
+
if not stripped and not description_lines:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Stop at sub-headers
|
|
124
|
+
if stripped.startswith("##"):
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
description_lines.append(stripped)
|
|
128
|
+
|
|
129
|
+
return " ".join(description_lines).strip()
|
|
130
|
+
|
|
131
|
+
def _extract_scenarios(
|
|
132
|
+
self, story_content: str, story_number: int
|
|
133
|
+
) -> list[AcceptanceScenario]:
|
|
134
|
+
"""Extract acceptance scenarios from story content.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
story_content: Content of a single user story section
|
|
138
|
+
story_number: The story number for generating IDs
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of AcceptanceScenario objects
|
|
142
|
+
"""
|
|
143
|
+
scenarios = []
|
|
144
|
+
|
|
145
|
+
# Try bold pattern first (more specific)
|
|
146
|
+
matches = list(self.SCENARIO_BOLD_PATTERN.finditer(story_content))
|
|
147
|
+
|
|
148
|
+
# Fall back to general pattern
|
|
149
|
+
if not matches:
|
|
150
|
+
matches = list(self.SCENARIO_PATTERN.finditer(story_content))
|
|
151
|
+
|
|
152
|
+
for scenario_num, match in enumerate(matches, start=1):
|
|
153
|
+
given = self._clean_clause(match.group(1))
|
|
154
|
+
when = self._clean_clause(match.group(2))
|
|
155
|
+
then = self._clean_clause(match.group(3))
|
|
156
|
+
|
|
157
|
+
scenario = AcceptanceScenario(
|
|
158
|
+
id=f"US{story_number}_S{scenario_num}",
|
|
159
|
+
scenario_number=scenario_num,
|
|
160
|
+
given_clause=given,
|
|
161
|
+
when_clause=when,
|
|
162
|
+
then_clause=then,
|
|
163
|
+
raw_text=match.group(0),
|
|
164
|
+
)
|
|
165
|
+
scenarios.append(scenario)
|
|
166
|
+
|
|
167
|
+
return scenarios
|
|
168
|
+
|
|
169
|
+
def _clean_clause(self, text: str) -> str:
|
|
170
|
+
"""Clean a Given/When/Then clause.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
text: Raw clause text
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Cleaned text with markdown removed
|
|
177
|
+
"""
|
|
178
|
+
# Remove bold markers
|
|
179
|
+
text = text.replace("**", "")
|
|
180
|
+
# Remove trailing punctuation
|
|
181
|
+
text = text.rstrip(".,;")
|
|
182
|
+
# Clean whitespace
|
|
183
|
+
text = " ".join(text.split())
|
|
184
|
+
return text.strip()
|
|
185
|
+
|
|
186
|
+
def _find_next_section(self, content: str, start_pos: int) -> Optional[int]:
|
|
187
|
+
"""Find the position of the next major section.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
content: Full file content
|
|
191
|
+
start_pos: Position to start searching from
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Position of next section header, or None if not found
|
|
195
|
+
"""
|
|
196
|
+
# Look for ## or ### headers that aren't user stories
|
|
197
|
+
section_pattern = re.compile(r"^##\s+[^#]", re.MULTILINE)
|
|
198
|
+
match = section_pattern.search(content, start_pos)
|
|
199
|
+
|
|
200
|
+
if match:
|
|
201
|
+
return match.start()
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def get_story_by_number(
|
|
206
|
+
self, content: str, story_number: int
|
|
207
|
+
) -> Optional[ParsedUserStory]:
|
|
208
|
+
"""Get a specific user story by number.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
content: Spec content to parse
|
|
212
|
+
story_number: Story number to find (1, 2, 3...)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
ParsedUserStory if found, None otherwise
|
|
216
|
+
"""
|
|
217
|
+
stories = self.parse(content)
|
|
218
|
+
for story in stories:
|
|
219
|
+
if story.story_number == story_number:
|
|
220
|
+
return story
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def count_stories(self, content: str) -> int:
|
|
224
|
+
"""Count user stories in content without full parsing.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
content: Spec content to check
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Number of user stories found
|
|
231
|
+
"""
|
|
232
|
+
return len(self.STORY_HEADER_PATTERN.findall(content))
|