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,699 @@
|
|
|
1
|
+
"""Service for orchestrating bug-fix workflow.
|
|
2
|
+
|
|
3
|
+
This module provides the FixitService class for managing
|
|
4
|
+
the complete bug-fix workflow lifecycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ..models.fixit_models import (
|
|
13
|
+
FindingType,
|
|
14
|
+
FixPhase,
|
|
15
|
+
FixWorkflow,
|
|
16
|
+
FixitWorkflowState,
|
|
17
|
+
GitHubIssue,
|
|
18
|
+
InvestigationCheckpoint,
|
|
19
|
+
InvestigationFinding,
|
|
20
|
+
InvestigationPlan,
|
|
21
|
+
IssueState,
|
|
22
|
+
)
|
|
23
|
+
from .github_service import GitHubService
|
|
24
|
+
from .state_manager import StateManager
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FixitServiceError(Exception):
|
|
28
|
+
"""Error raised when fixit workflow operations fail."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FixitService:
|
|
34
|
+
"""Orchestrates the complete bug-fix workflow lifecycle.
|
|
35
|
+
|
|
36
|
+
Manages workflow state, coordinates with GitHub for issue operations,
|
|
37
|
+
and provides methods for each workflow phase.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
MAX_BRANCH_NAME_LENGTH = 60
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
github_service: Optional[GitHubService] = None,
|
|
45
|
+
state_manager: Optional[StateManager] = None,
|
|
46
|
+
):
|
|
47
|
+
"""Initialize the fixit service.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
github_service: GitHubService instance for issue operations.
|
|
51
|
+
state_manager: StateManager instance for state persistence.
|
|
52
|
+
"""
|
|
53
|
+
self.github = github_service or GitHubService()
|
|
54
|
+
self.state_manager = state_manager or StateManager()
|
|
55
|
+
|
|
56
|
+
# =========================================================================
|
|
57
|
+
# Workflow Lifecycle Methods
|
|
58
|
+
# =========================================================================
|
|
59
|
+
|
|
60
|
+
def start_workflow(
|
|
61
|
+
self,
|
|
62
|
+
issue_id: int,
|
|
63
|
+
resume: bool = False,
|
|
64
|
+
manual_branch: Optional[str] = None,
|
|
65
|
+
) -> FixWorkflow:
|
|
66
|
+
"""Start a new bug-fix workflow for an issue.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
issue_id: GitHub issue number to fix.
|
|
70
|
+
resume: If True, resume existing workflow instead of failing.
|
|
71
|
+
manual_branch: Optional custom branch name.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
FixWorkflow for the started workflow.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
FixitServiceError: If issue not found, closed, or branch exists.
|
|
78
|
+
"""
|
|
79
|
+
# Check for existing workflow
|
|
80
|
+
existing_state = self.state_manager.load_fixit_state(issue_id)
|
|
81
|
+
if existing_state:
|
|
82
|
+
if resume:
|
|
83
|
+
return FixWorkflow.from_dict(existing_state["workflow"])
|
|
84
|
+
raise FixitServiceError(
|
|
85
|
+
f"Workflow already exists for issue #{issue_id}. "
|
|
86
|
+
"Use --resume to continue or cancel the existing workflow."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Fetch and validate issue
|
|
90
|
+
issue = self.github.get_issue(issue_id)
|
|
91
|
+
if issue is None:
|
|
92
|
+
raise FixitServiceError(f"Issue #{issue_id} not found.")
|
|
93
|
+
if issue.state == IssueState.CLOSED:
|
|
94
|
+
raise FixitServiceError(
|
|
95
|
+
f"Issue #{issue_id} is already closed. Cannot start workflow."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Determine branch name
|
|
99
|
+
branch_name = manual_branch or self._create_branch_name(issue_id, issue.title)
|
|
100
|
+
|
|
101
|
+
# Check if branch already exists
|
|
102
|
+
local_exists, remote_exists = self.github.check_branch_exists(branch_name)
|
|
103
|
+
if local_exists or remote_exists:
|
|
104
|
+
raise FixitServiceError(
|
|
105
|
+
f"Branch '{branch_name}' already exists. "
|
|
106
|
+
"Use --branch to specify a different name."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Create the branch
|
|
110
|
+
if not self.github.create_branch(branch_name):
|
|
111
|
+
raise FixitServiceError(f"Failed to create branch '{branch_name}'.")
|
|
112
|
+
|
|
113
|
+
# Create workflow
|
|
114
|
+
workflow = FixWorkflow(
|
|
115
|
+
id=f"fixit-{issue_id}",
|
|
116
|
+
issue_id=issue_id,
|
|
117
|
+
branch_name=branch_name,
|
|
118
|
+
phase=FixPhase.INVESTIGATING,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Save state
|
|
122
|
+
state = FixitWorkflowState(workflow=workflow, issue=issue)
|
|
123
|
+
self.state_manager.save_fixit_state(state.to_dict(), issue_id)
|
|
124
|
+
|
|
125
|
+
return workflow
|
|
126
|
+
|
|
127
|
+
def cancel_workflow(self, issue_id: int) -> bool:
|
|
128
|
+
"""Cancel an active workflow.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
issue_id: GitHub issue number.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if cancelled, False if not found.
|
|
135
|
+
"""
|
|
136
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
137
|
+
if not state_data:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
# Update phase to cancelled
|
|
141
|
+
state_data["workflow"]["phase"] = FixPhase.CANCELLED.value
|
|
142
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
143
|
+
|
|
144
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
def complete_workflow(self, issue_id: int, close_issue: bool = True) -> bool:
|
|
148
|
+
"""Complete a workflow after successful fix.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
issue_id: GitHub issue number.
|
|
152
|
+
close_issue: Whether to close the GitHub issue.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if completed, False if not found.
|
|
156
|
+
"""
|
|
157
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
158
|
+
if not state_data:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
# Update phase to completed
|
|
162
|
+
state_data["workflow"]["phase"] = FixPhase.COMPLETED.value
|
|
163
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
164
|
+
|
|
165
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
166
|
+
|
|
167
|
+
# Optionally close the issue
|
|
168
|
+
if close_issue:
|
|
169
|
+
self.github.close_issue(
|
|
170
|
+
issue_id,
|
|
171
|
+
comment="Fixed via doit fixit workflow."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
# =========================================================================
|
|
177
|
+
# Query Methods
|
|
178
|
+
# =========================================================================
|
|
179
|
+
|
|
180
|
+
def get_active_workflow(self) -> Optional[FixWorkflow]:
|
|
181
|
+
"""Get the currently active workflow.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
FixWorkflow if active workflow exists, None otherwise.
|
|
185
|
+
"""
|
|
186
|
+
result = self.state_manager.get_active_fixit_workflow()
|
|
187
|
+
if result is None:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
issue_id, state_data = result
|
|
191
|
+
return FixWorkflow.from_dict(state_data["workflow"])
|
|
192
|
+
|
|
193
|
+
def get_workflow(self, issue_id: int) -> Optional[FixWorkflow]:
|
|
194
|
+
"""Get workflow for a specific issue.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
issue_id: GitHub issue number.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
FixWorkflow if exists, None otherwise.
|
|
201
|
+
"""
|
|
202
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
203
|
+
if not state_data:
|
|
204
|
+
return None
|
|
205
|
+
return FixWorkflow.from_dict(state_data["workflow"])
|
|
206
|
+
|
|
207
|
+
def get_workflow_state(self, issue_id: int) -> Optional[FixitWorkflowState]:
|
|
208
|
+
"""Get complete workflow state for an issue.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
issue_id: GitHub issue number.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
FixitWorkflowState if exists, None otherwise.
|
|
215
|
+
"""
|
|
216
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
217
|
+
if not state_data:
|
|
218
|
+
return None
|
|
219
|
+
return FixitWorkflowState.from_dict(state_data)
|
|
220
|
+
|
|
221
|
+
def list_bugs(self, label: str = "bug", limit: int = 20) -> list[GitHubIssue]:
|
|
222
|
+
"""List open bugs from GitHub.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
label: Label to filter by.
|
|
226
|
+
limit: Maximum number to return.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of GitHubIssue objects.
|
|
230
|
+
"""
|
|
231
|
+
return self.github.list_bugs(label=label, limit=limit)
|
|
232
|
+
|
|
233
|
+
def list_workflows(self) -> list[tuple[int, FixWorkflow]]:
|
|
234
|
+
"""List all fixit workflows.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
List of (issue_id, workflow) tuples.
|
|
238
|
+
"""
|
|
239
|
+
states = self.state_manager.list_fixit_states()
|
|
240
|
+
workflows = []
|
|
241
|
+
for issue_id, state_data in states:
|
|
242
|
+
try:
|
|
243
|
+
workflow = FixWorkflow.from_dict(state_data["workflow"])
|
|
244
|
+
workflows.append((issue_id, workflow))
|
|
245
|
+
except (KeyError, ValueError):
|
|
246
|
+
continue
|
|
247
|
+
return workflows
|
|
248
|
+
|
|
249
|
+
# =========================================================================
|
|
250
|
+
# Phase Transition Methods
|
|
251
|
+
# =========================================================================
|
|
252
|
+
|
|
253
|
+
def advance_phase(self, issue_id: int, to_phase: FixPhase) -> bool:
|
|
254
|
+
"""Advance workflow to a new phase.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
issue_id: GitHub issue number.
|
|
258
|
+
to_phase: Target phase.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
True if advanced, False if not allowed or not found.
|
|
262
|
+
"""
|
|
263
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
264
|
+
if not state_data:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
current_phase = FixPhase(state_data["workflow"]["phase"])
|
|
268
|
+
|
|
269
|
+
# Validate phase transition
|
|
270
|
+
if not self._is_valid_transition(current_phase, to_phase):
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
state_data["workflow"]["phase"] = to_phase.value
|
|
274
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
275
|
+
|
|
276
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
def _is_valid_transition(self, from_phase: FixPhase, to_phase: FixPhase) -> bool:
|
|
280
|
+
"""Check if a phase transition is valid.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
from_phase: Current phase.
|
|
284
|
+
to_phase: Target phase.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True if transition is allowed.
|
|
288
|
+
"""
|
|
289
|
+
valid_transitions = {
|
|
290
|
+
FixPhase.INITIALIZED: {FixPhase.INVESTIGATING, FixPhase.CANCELLED},
|
|
291
|
+
FixPhase.INVESTIGATING: {FixPhase.PLANNING, FixPhase.CANCELLED},
|
|
292
|
+
FixPhase.PLANNING: {FixPhase.REVIEWING, FixPhase.CANCELLED},
|
|
293
|
+
FixPhase.REVIEWING: {FixPhase.APPROVED, FixPhase.PLANNING},
|
|
294
|
+
FixPhase.APPROVED: {FixPhase.IMPLEMENTING},
|
|
295
|
+
FixPhase.IMPLEMENTING: {FixPhase.COMPLETED},
|
|
296
|
+
FixPhase.COMPLETED: set(),
|
|
297
|
+
FixPhase.CANCELLED: set(),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return to_phase in valid_transitions.get(from_phase, set())
|
|
301
|
+
|
|
302
|
+
# =========================================================================
|
|
303
|
+
# Investigation Methods (T022-T024)
|
|
304
|
+
# =========================================================================
|
|
305
|
+
|
|
306
|
+
def start_investigation(self, issue_id: int) -> Optional["InvestigationPlan"]:
|
|
307
|
+
"""Start investigation for a workflow.
|
|
308
|
+
|
|
309
|
+
Creates an InvestigationPlan with keywords extracted from the issue.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
issue_id: GitHub issue number.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
InvestigationPlan if created, None if workflow not found.
|
|
316
|
+
"""
|
|
317
|
+
from uuid import uuid4
|
|
318
|
+
|
|
319
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
320
|
+
if not state_data:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
# Extract keywords from issue
|
|
324
|
+
issue_data = state_data.get("issue")
|
|
325
|
+
keywords = []
|
|
326
|
+
if issue_data:
|
|
327
|
+
# Extract words from title and body
|
|
328
|
+
text = f"{issue_data.get('title', '')} {issue_data.get('body', '')}"
|
|
329
|
+
# Simple keyword extraction - split and filter
|
|
330
|
+
words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower())
|
|
331
|
+
# Remove common words and deduplicate
|
|
332
|
+
stopwords = {"the", "and", "for", "with", "this", "that", "from", "when"}
|
|
333
|
+
keywords = list(dict.fromkeys(w for w in words if w not in stopwords))[:10]
|
|
334
|
+
|
|
335
|
+
# Create investigation plan
|
|
336
|
+
plan = InvestigationPlan(
|
|
337
|
+
id=f"inv-{uuid4().hex[:8]}",
|
|
338
|
+
workflow_id=state_data["workflow"]["id"],
|
|
339
|
+
keywords=keywords,
|
|
340
|
+
checkpoints=[
|
|
341
|
+
InvestigationCheckpoint(id=f"cp-{uuid4().hex[:6]}", title="Review error logs and stack traces"),
|
|
342
|
+
InvestigationCheckpoint(id=f"cp-{uuid4().hex[:6]}", title="Identify affected code paths"),
|
|
343
|
+
InvestigationCheckpoint(id=f"cp-{uuid4().hex[:6]}", title="Search for related issues or commits"),
|
|
344
|
+
InvestigationCheckpoint(id=f"cp-{uuid4().hex[:6]}", title="Formulate root cause hypothesis"),
|
|
345
|
+
],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Save updated state
|
|
349
|
+
state_data["investigation_plan"] = plan.to_dict()
|
|
350
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
351
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
352
|
+
|
|
353
|
+
return plan
|
|
354
|
+
|
|
355
|
+
def add_finding(
|
|
356
|
+
self,
|
|
357
|
+
issue_id: int,
|
|
358
|
+
finding_type: "FindingType",
|
|
359
|
+
description: str,
|
|
360
|
+
evidence: str = "",
|
|
361
|
+
file_path: Optional[str] = None,
|
|
362
|
+
line_number: Optional[int] = None,
|
|
363
|
+
) -> Optional["InvestigationFinding"]:
|
|
364
|
+
"""Add a finding to the investigation.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
issue_id: GitHub issue number.
|
|
368
|
+
finding_type: Type of finding.
|
|
369
|
+
description: Description of the finding.
|
|
370
|
+
evidence: Supporting evidence.
|
|
371
|
+
file_path: Related source file.
|
|
372
|
+
line_number: Line number in file.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
InvestigationFinding if added, None if no plan exists.
|
|
376
|
+
"""
|
|
377
|
+
from uuid import uuid4
|
|
378
|
+
|
|
379
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
380
|
+
if not state_data:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
plan_data = state_data.get("investigation_plan")
|
|
384
|
+
if not plan_data:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
# Create finding
|
|
388
|
+
finding = InvestigationFinding(
|
|
389
|
+
id=f"f-{uuid4().hex[:8]}",
|
|
390
|
+
finding_type=finding_type,
|
|
391
|
+
description=description,
|
|
392
|
+
evidence=evidence,
|
|
393
|
+
file_path=file_path,
|
|
394
|
+
line_number=line_number,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Add to plan
|
|
398
|
+
if "findings" not in plan_data:
|
|
399
|
+
plan_data["findings"] = []
|
|
400
|
+
plan_data["findings"].append(finding.to_dict())
|
|
401
|
+
|
|
402
|
+
# Save updated state
|
|
403
|
+
state_data["investigation_plan"] = plan_data
|
|
404
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
405
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
406
|
+
|
|
407
|
+
return finding
|
|
408
|
+
|
|
409
|
+
def complete_checkpoint(
|
|
410
|
+
self,
|
|
411
|
+
issue_id: int,
|
|
412
|
+
checkpoint_id: str,
|
|
413
|
+
notes: str = "",
|
|
414
|
+
) -> bool:
|
|
415
|
+
"""Mark a checkpoint as completed.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
issue_id: GitHub issue number.
|
|
419
|
+
checkpoint_id: ID of checkpoint to complete.
|
|
420
|
+
notes: Optional notes about completion.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
True if completed, False if checkpoint not found.
|
|
424
|
+
"""
|
|
425
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
426
|
+
if not state_data:
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
plan_data = state_data.get("investigation_plan")
|
|
430
|
+
if not plan_data:
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
# Find and update checkpoint
|
|
434
|
+
checkpoints = plan_data.get("checkpoints", [])
|
|
435
|
+
found = False
|
|
436
|
+
for cp in checkpoints:
|
|
437
|
+
if cp["id"] == checkpoint_id:
|
|
438
|
+
cp["completed"] = True
|
|
439
|
+
cp["notes"] = notes
|
|
440
|
+
found = True
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
if not found:
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
# Save updated state
|
|
447
|
+
state_data["investigation_plan"]["checkpoints"] = checkpoints
|
|
448
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
449
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
450
|
+
|
|
451
|
+
return True
|
|
452
|
+
|
|
453
|
+
def complete_investigation(self, issue_id: int) -> bool:
|
|
454
|
+
"""Complete investigation and advance to planning phase.
|
|
455
|
+
|
|
456
|
+
Requires at least one confirmed_cause finding.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
issue_id: GitHub issue number.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
True if completed, False if no root cause found.
|
|
463
|
+
"""
|
|
464
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
465
|
+
if not state_data:
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
plan_data = state_data.get("investigation_plan")
|
|
469
|
+
if not plan_data:
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
# Check for confirmed cause
|
|
473
|
+
findings = plan_data.get("findings", [])
|
|
474
|
+
has_root_cause = any(f.get("type") == "confirmed_cause" for f in findings)
|
|
475
|
+
|
|
476
|
+
if not has_root_cause:
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
# Advance to planning phase
|
|
480
|
+
state_data["workflow"]["phase"] = FixPhase.PLANNING.value
|
|
481
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
482
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
483
|
+
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
def get_investigation_plan(self, issue_id: int) -> Optional["InvestigationPlan"]:
|
|
487
|
+
"""Get the investigation plan for a workflow.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
issue_id: GitHub issue number.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
InvestigationPlan if exists, None otherwise.
|
|
494
|
+
"""
|
|
495
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
496
|
+
if not state_data:
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
plan_data = state_data.get("investigation_plan")
|
|
500
|
+
if not plan_data:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
return InvestigationPlan.from_dict(plan_data)
|
|
504
|
+
|
|
505
|
+
# =========================================================================
|
|
506
|
+
# Fix Plan Methods (T028)
|
|
507
|
+
# =========================================================================
|
|
508
|
+
|
|
509
|
+
def generate_fix_plan(self, issue_id: int) -> Optional["FixPlan"]:
|
|
510
|
+
"""Generate a fix plan from investigation findings.
|
|
511
|
+
|
|
512
|
+
Creates a FixPlan with root cause from confirmed_cause findings
|
|
513
|
+
and proposed file changes from affected_file findings.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
issue_id: GitHub issue number.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
FixPlan if created, None if no workflow or no confirmed cause.
|
|
520
|
+
"""
|
|
521
|
+
from uuid import uuid4
|
|
522
|
+
from ..models.fixit_models import ChangeType, FixPlan, FileChange, PlanStatus, RiskLevel
|
|
523
|
+
|
|
524
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
525
|
+
if not state_data:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
# Get investigation plan
|
|
529
|
+
plan_data = state_data.get("investigation_plan")
|
|
530
|
+
if not plan_data:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
findings = plan_data.get("findings", [])
|
|
534
|
+
|
|
535
|
+
# Find confirmed cause
|
|
536
|
+
confirmed_causes = [f for f in findings if f.get("type") == "confirmed_cause"]
|
|
537
|
+
if not confirmed_causes:
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
# Build root cause from confirmed_cause findings
|
|
541
|
+
root_cause = "\n".join(f["description"] for f in confirmed_causes)
|
|
542
|
+
|
|
543
|
+
# Build file changes from affected_file findings
|
|
544
|
+
file_changes = []
|
|
545
|
+
for f in findings:
|
|
546
|
+
if f.get("type") == "affected_file" and f.get("file_path"):
|
|
547
|
+
file_changes.append(FileChange(
|
|
548
|
+
file_path=f["file_path"],
|
|
549
|
+
change_type=ChangeType.MODIFY,
|
|
550
|
+
description=f["description"],
|
|
551
|
+
))
|
|
552
|
+
|
|
553
|
+
# Also include files from confirmed_cause
|
|
554
|
+
for f in confirmed_causes:
|
|
555
|
+
if f.get("file_path"):
|
|
556
|
+
file_changes.append(FileChange(
|
|
557
|
+
file_path=f["file_path"],
|
|
558
|
+
change_type=ChangeType.MODIFY,
|
|
559
|
+
description=f"Fix: {f['description']}",
|
|
560
|
+
))
|
|
561
|
+
|
|
562
|
+
# Create fix plan
|
|
563
|
+
fix_plan = FixPlan(
|
|
564
|
+
id=f"plan-{uuid4().hex[:8]}",
|
|
565
|
+
workflow_id=state_data["workflow"]["id"],
|
|
566
|
+
root_cause=root_cause,
|
|
567
|
+
proposed_solution="Apply fix based on confirmed root cause analysis",
|
|
568
|
+
affected_files=file_changes,
|
|
569
|
+
risk_level=RiskLevel.LOW if len(file_changes) <= 2 else RiskLevel.MEDIUM,
|
|
570
|
+
status=PlanStatus.DRAFT,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Save updated state
|
|
574
|
+
state_data["fix_plan"] = fix_plan.to_dict()
|
|
575
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
576
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
577
|
+
|
|
578
|
+
return fix_plan
|
|
579
|
+
|
|
580
|
+
def get_fix_plan(self, issue_id: int) -> Optional["FixPlan"]:
|
|
581
|
+
"""Get the fix plan for a workflow.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
issue_id: GitHub issue number.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
FixPlan if exists, None otherwise.
|
|
588
|
+
"""
|
|
589
|
+
from ..models.fixit_models import FixPlan
|
|
590
|
+
|
|
591
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
592
|
+
if not state_data:
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
plan_data = state_data.get("fix_plan")
|
|
596
|
+
if not plan_data:
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
return FixPlan.from_dict(plan_data)
|
|
600
|
+
|
|
601
|
+
def approve_plan(self, issue_id: int) -> bool:
|
|
602
|
+
"""Approve a fix plan and advance to approved phase.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
issue_id: GitHub issue number.
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
True if approved, False if no plan exists.
|
|
609
|
+
"""
|
|
610
|
+
from ..models.fixit_models import PlanStatus
|
|
611
|
+
|
|
612
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
613
|
+
if not state_data:
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
plan_data = state_data.get("fix_plan")
|
|
617
|
+
if not plan_data:
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
# Update plan status
|
|
621
|
+
plan_data["status"] = PlanStatus.APPROVED.value
|
|
622
|
+
state_data["fix_plan"] = plan_data
|
|
623
|
+
|
|
624
|
+
# Advance workflow phase
|
|
625
|
+
state_data["workflow"]["phase"] = FixPhase.APPROVED.value
|
|
626
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
627
|
+
|
|
628
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
def submit_for_review(self, issue_id: int) -> bool:
|
|
632
|
+
"""Submit fix plan for review.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
issue_id: GitHub issue number.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
True if submitted, False if no plan exists.
|
|
639
|
+
"""
|
|
640
|
+
from ..models.fixit_models import PlanStatus
|
|
641
|
+
|
|
642
|
+
state_data = self.state_manager.load_fixit_state(issue_id)
|
|
643
|
+
if not state_data:
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
plan_data = state_data.get("fix_plan")
|
|
647
|
+
if not plan_data:
|
|
648
|
+
return False
|
|
649
|
+
|
|
650
|
+
# Update plan status
|
|
651
|
+
plan_data["status"] = PlanStatus.PENDING_REVIEW.value
|
|
652
|
+
state_data["fix_plan"] = plan_data
|
|
653
|
+
|
|
654
|
+
# Advance workflow phase to reviewing
|
|
655
|
+
state_data["workflow"]["phase"] = FixPhase.REVIEWING.value
|
|
656
|
+
state_data["workflow"]["updated_at"] = datetime.now().isoformat()
|
|
657
|
+
|
|
658
|
+
self.state_manager.save_fixit_state(state_data, issue_id)
|
|
659
|
+
return True
|
|
660
|
+
|
|
661
|
+
# =========================================================================
|
|
662
|
+
# Helper Methods
|
|
663
|
+
# =========================================================================
|
|
664
|
+
|
|
665
|
+
def _create_branch_name(self, issue_id: int, title: str) -> str:
|
|
666
|
+
"""Create a branch name from issue ID and title.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
issue_id: GitHub issue number.
|
|
670
|
+
title: Issue title.
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
Branch name in format: fix/{issue_id}-{slug}
|
|
674
|
+
"""
|
|
675
|
+
# Convert to lowercase and replace non-alphanumeric with hyphens
|
|
676
|
+
slug = title.lower()
|
|
677
|
+
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
|
678
|
+
slug = slug.strip("-")
|
|
679
|
+
|
|
680
|
+
# Build branch name
|
|
681
|
+
branch_name = f"fix/{issue_id}-{slug}"
|
|
682
|
+
|
|
683
|
+
# Truncate if too long
|
|
684
|
+
if len(branch_name) > self.MAX_BRANCH_NAME_LENGTH:
|
|
685
|
+
# Keep fix/{issue_id}- prefix, truncate slug
|
|
686
|
+
prefix = f"fix/{issue_id}-"
|
|
687
|
+
max_slug_length = self.MAX_BRANCH_NAME_LENGTH - len(prefix)
|
|
688
|
+
slug = slug[:max_slug_length].rstrip("-")
|
|
689
|
+
branch_name = f"{prefix}{slug}"
|
|
690
|
+
|
|
691
|
+
return branch_name
|
|
692
|
+
|
|
693
|
+
def is_github_available(self) -> bool:
|
|
694
|
+
"""Check if GitHub API is available.
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
True if GitHub is accessible.
|
|
698
|
+
"""
|
|
699
|
+
return self.github.is_available()
|