doit-toolkit-cli 0.1.10__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.

Potentially problematic release.


This version of doit-toolkit-cli might be problematic. Click here for more details.

Files changed (135) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/roadmapit_command.py +10 -0
  11. doit_cli/cli/status_command.py +117 -0
  12. doit_cli/cli/sync_prompts_command.py +248 -0
  13. doit_cli/cli/validate_command.py +196 -0
  14. doit_cli/cli/verify_command.py +204 -0
  15. doit_cli/cli/workflow_mixin.py +224 -0
  16. doit_cli/cli/xref_command.py +555 -0
  17. doit_cli/formatters/__init__.py +8 -0
  18. doit_cli/formatters/base.py +38 -0
  19. doit_cli/formatters/json_formatter.py +126 -0
  20. doit_cli/formatters/markdown_formatter.py +97 -0
  21. doit_cli/formatters/rich_formatter.py +257 -0
  22. doit_cli/main.py +51 -0
  23. doit_cli/models/__init__.py +139 -0
  24. doit_cli/models/agent.py +74 -0
  25. doit_cli/models/analytics_models.py +384 -0
  26. doit_cli/models/context_config.py +464 -0
  27. doit_cli/models/crossref_models.py +182 -0
  28. doit_cli/models/diagram_models.py +363 -0
  29. doit_cli/models/fixit_models.py +355 -0
  30. doit_cli/models/hook_config.py +125 -0
  31. doit_cli/models/project.py +91 -0
  32. doit_cli/models/results.py +121 -0
  33. doit_cli/models/search_models.py +228 -0
  34. doit_cli/models/status_models.py +195 -0
  35. doit_cli/models/sync_models.py +146 -0
  36. doit_cli/models/template.py +77 -0
  37. doit_cli/models/validation_models.py +175 -0
  38. doit_cli/models/workflow_models.py +319 -0
  39. doit_cli/prompts/__init__.py +5 -0
  40. doit_cli/prompts/fixit_prompts.py +344 -0
  41. doit_cli/prompts/interactive.py +390 -0
  42. doit_cli/rules/__init__.py +5 -0
  43. doit_cli/rules/builtin_rules.py +160 -0
  44. doit_cli/services/__init__.py +79 -0
  45. doit_cli/services/agent_detector.py +168 -0
  46. doit_cli/services/analytics_service.py +218 -0
  47. doit_cli/services/architecture_generator.py +290 -0
  48. doit_cli/services/backup_service.py +204 -0
  49. doit_cli/services/config_loader.py +113 -0
  50. doit_cli/services/context_loader.py +1123 -0
  51. doit_cli/services/coverage_calculator.py +142 -0
  52. doit_cli/services/crossref_service.py +237 -0
  53. doit_cli/services/cycle_time_calculator.py +134 -0
  54. doit_cli/services/date_inferrer.py +349 -0
  55. doit_cli/services/diagram_service.py +337 -0
  56. doit_cli/services/drift_detector.py +109 -0
  57. doit_cli/services/entity_parser.py +301 -0
  58. doit_cli/services/er_diagram_generator.py +197 -0
  59. doit_cli/services/fixit_service.py +699 -0
  60. doit_cli/services/github_service.py +192 -0
  61. doit_cli/services/hook_manager.py +258 -0
  62. doit_cli/services/hook_validator.py +528 -0
  63. doit_cli/services/input_validator.py +322 -0
  64. doit_cli/services/memory_search.py +527 -0
  65. doit_cli/services/mermaid_validator.py +334 -0
  66. doit_cli/services/prompt_transformer.py +91 -0
  67. doit_cli/services/prompt_writer.py +133 -0
  68. doit_cli/services/query_interpreter.py +428 -0
  69. doit_cli/services/report_exporter.py +219 -0
  70. doit_cli/services/report_generator.py +256 -0
  71. doit_cli/services/requirement_parser.py +112 -0
  72. doit_cli/services/roadmap_summarizer.py +209 -0
  73. doit_cli/services/rule_engine.py +443 -0
  74. doit_cli/services/scaffolder.py +215 -0
  75. doit_cli/services/score_calculator.py +172 -0
  76. doit_cli/services/section_parser.py +204 -0
  77. doit_cli/services/spec_scanner.py +327 -0
  78. doit_cli/services/state_manager.py +355 -0
  79. doit_cli/services/status_reporter.py +143 -0
  80. doit_cli/services/task_parser.py +347 -0
  81. doit_cli/services/template_manager.py +710 -0
  82. doit_cli/services/template_reader.py +158 -0
  83. doit_cli/services/user_journey_generator.py +214 -0
  84. doit_cli/services/user_story_parser.py +232 -0
  85. doit_cli/services/validation_service.py +188 -0
  86. doit_cli/services/validator.py +232 -0
  87. doit_cli/services/velocity_tracker.py +173 -0
  88. doit_cli/services/workflow_engine.py +405 -0
  89. doit_cli/templates/agent-file-template.md +28 -0
  90. doit_cli/templates/checklist-template.md +39 -0
  91. doit_cli/templates/commands/doit.checkin.md +363 -0
  92. doit_cli/templates/commands/doit.constitution.md +187 -0
  93. doit_cli/templates/commands/doit.documentit.md +485 -0
  94. doit_cli/templates/commands/doit.fixit.md +181 -0
  95. doit_cli/templates/commands/doit.implementit.md +265 -0
  96. doit_cli/templates/commands/doit.planit.md +262 -0
  97. doit_cli/templates/commands/doit.reviewit.md +355 -0
  98. doit_cli/templates/commands/doit.roadmapit.md +389 -0
  99. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  100. doit_cli/templates/commands/doit.specit.md +521 -0
  101. doit_cli/templates/commands/doit.taskit.md +304 -0
  102. doit_cli/templates/commands/doit.testit.md +277 -0
  103. doit_cli/templates/config/context.yaml +134 -0
  104. doit_cli/templates/config/hooks.yaml +93 -0
  105. doit_cli/templates/config/validation-rules.yaml +64 -0
  106. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  107. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  108. doit_cli/templates/github-issue-templates/task.yml +129 -0
  109. doit_cli/templates/hooks/.gitkeep +0 -0
  110. doit_cli/templates/hooks/post-commit.sh +25 -0
  111. doit_cli/templates/hooks/post-merge.sh +75 -0
  112. doit_cli/templates/hooks/pre-commit.sh +17 -0
  113. doit_cli/templates/hooks/pre-push.sh +18 -0
  114. doit_cli/templates/memory/completed_roadmap.md +50 -0
  115. doit_cli/templates/memory/constitution.md +125 -0
  116. doit_cli/templates/memory/roadmap.md +61 -0
  117. doit_cli/templates/plan-template.md +146 -0
  118. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  119. doit_cli/templates/scripts/bash/common.sh +156 -0
  120. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  121. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  122. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  123. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  124. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  125. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  126. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  127. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  128. doit_cli/templates/spec-template.md +159 -0
  129. doit_cli/templates/tasks-template.md +313 -0
  130. doit_cli/templates/vscode-settings.json +14 -0
  131. doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
  132. doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
  133. doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
  134. doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
  135. doit_toolkit_cli-0.1.10.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()