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.
Files changed (134) 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/status_command.py +117 -0
  11. doit_cli/cli/sync_prompts_command.py +248 -0
  12. doit_cli/cli/validate_command.py +196 -0
  13. doit_cli/cli/verify_command.py +204 -0
  14. doit_cli/cli/workflow_mixin.py +224 -0
  15. doit_cli/cli/xref_command.py +555 -0
  16. doit_cli/formatters/__init__.py +8 -0
  17. doit_cli/formatters/base.py +38 -0
  18. doit_cli/formatters/json_formatter.py +126 -0
  19. doit_cli/formatters/markdown_formatter.py +97 -0
  20. doit_cli/formatters/rich_formatter.py +257 -0
  21. doit_cli/main.py +49 -0
  22. doit_cli/models/__init__.py +139 -0
  23. doit_cli/models/agent.py +74 -0
  24. doit_cli/models/analytics_models.py +384 -0
  25. doit_cli/models/context_config.py +464 -0
  26. doit_cli/models/crossref_models.py +182 -0
  27. doit_cli/models/diagram_models.py +363 -0
  28. doit_cli/models/fixit_models.py +355 -0
  29. doit_cli/models/hook_config.py +125 -0
  30. doit_cli/models/project.py +91 -0
  31. doit_cli/models/results.py +121 -0
  32. doit_cli/models/search_models.py +228 -0
  33. doit_cli/models/status_models.py +195 -0
  34. doit_cli/models/sync_models.py +146 -0
  35. doit_cli/models/template.py +77 -0
  36. doit_cli/models/validation_models.py +175 -0
  37. doit_cli/models/workflow_models.py +319 -0
  38. doit_cli/prompts/__init__.py +5 -0
  39. doit_cli/prompts/fixit_prompts.py +344 -0
  40. doit_cli/prompts/interactive.py +390 -0
  41. doit_cli/rules/__init__.py +5 -0
  42. doit_cli/rules/builtin_rules.py +160 -0
  43. doit_cli/services/__init__.py +79 -0
  44. doit_cli/services/agent_detector.py +168 -0
  45. doit_cli/services/analytics_service.py +218 -0
  46. doit_cli/services/architecture_generator.py +290 -0
  47. doit_cli/services/backup_service.py +204 -0
  48. doit_cli/services/config_loader.py +113 -0
  49. doit_cli/services/context_loader.py +1121 -0
  50. doit_cli/services/coverage_calculator.py +142 -0
  51. doit_cli/services/crossref_service.py +237 -0
  52. doit_cli/services/cycle_time_calculator.py +134 -0
  53. doit_cli/services/date_inferrer.py +349 -0
  54. doit_cli/services/diagram_service.py +337 -0
  55. doit_cli/services/drift_detector.py +109 -0
  56. doit_cli/services/entity_parser.py +301 -0
  57. doit_cli/services/er_diagram_generator.py +197 -0
  58. doit_cli/services/fixit_service.py +699 -0
  59. doit_cli/services/github_service.py +192 -0
  60. doit_cli/services/hook_manager.py +258 -0
  61. doit_cli/services/hook_validator.py +528 -0
  62. doit_cli/services/input_validator.py +322 -0
  63. doit_cli/services/memory_search.py +527 -0
  64. doit_cli/services/mermaid_validator.py +334 -0
  65. doit_cli/services/prompt_transformer.py +91 -0
  66. doit_cli/services/prompt_writer.py +133 -0
  67. doit_cli/services/query_interpreter.py +428 -0
  68. doit_cli/services/report_exporter.py +219 -0
  69. doit_cli/services/report_generator.py +256 -0
  70. doit_cli/services/requirement_parser.py +112 -0
  71. doit_cli/services/roadmap_summarizer.py +209 -0
  72. doit_cli/services/rule_engine.py +443 -0
  73. doit_cli/services/scaffolder.py +215 -0
  74. doit_cli/services/score_calculator.py +172 -0
  75. doit_cli/services/section_parser.py +204 -0
  76. doit_cli/services/spec_scanner.py +327 -0
  77. doit_cli/services/state_manager.py +355 -0
  78. doit_cli/services/status_reporter.py +143 -0
  79. doit_cli/services/task_parser.py +347 -0
  80. doit_cli/services/template_manager.py +710 -0
  81. doit_cli/services/template_reader.py +158 -0
  82. doit_cli/services/user_journey_generator.py +214 -0
  83. doit_cli/services/user_story_parser.py +232 -0
  84. doit_cli/services/validation_service.py +188 -0
  85. doit_cli/services/validator.py +232 -0
  86. doit_cli/services/velocity_tracker.py +173 -0
  87. doit_cli/services/workflow_engine.py +405 -0
  88. doit_cli/templates/agent-file-template.md +28 -0
  89. doit_cli/templates/checklist-template.md +39 -0
  90. doit_cli/templates/commands/doit.checkin.md +363 -0
  91. doit_cli/templates/commands/doit.constitution.md +187 -0
  92. doit_cli/templates/commands/doit.documentit.md +485 -0
  93. doit_cli/templates/commands/doit.fixit.md +181 -0
  94. doit_cli/templates/commands/doit.implementit.md +265 -0
  95. doit_cli/templates/commands/doit.planit.md +262 -0
  96. doit_cli/templates/commands/doit.reviewit.md +355 -0
  97. doit_cli/templates/commands/doit.roadmapit.md +368 -0
  98. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  99. doit_cli/templates/commands/doit.specit.md +521 -0
  100. doit_cli/templates/commands/doit.taskit.md +304 -0
  101. doit_cli/templates/commands/doit.testit.md +277 -0
  102. doit_cli/templates/config/context.yaml +134 -0
  103. doit_cli/templates/config/hooks.yaml +93 -0
  104. doit_cli/templates/config/validation-rules.yaml +64 -0
  105. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  106. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  107. doit_cli/templates/github-issue-templates/task.yml +129 -0
  108. doit_cli/templates/hooks/.gitkeep +0 -0
  109. doit_cli/templates/hooks/post-commit.sh +25 -0
  110. doit_cli/templates/hooks/post-merge.sh +75 -0
  111. doit_cli/templates/hooks/pre-commit.sh +17 -0
  112. doit_cli/templates/hooks/pre-push.sh +18 -0
  113. doit_cli/templates/memory/completed_roadmap.md +50 -0
  114. doit_cli/templates/memory/constitution.md +125 -0
  115. doit_cli/templates/memory/roadmap.md +61 -0
  116. doit_cli/templates/plan-template.md +146 -0
  117. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  118. doit_cli/templates/scripts/bash/common.sh +156 -0
  119. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  120. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  121. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  122. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  123. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  124. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  125. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  126. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  127. doit_cli/templates/spec-template.md +159 -0
  128. doit_cli/templates/tasks-template.md +313 -0
  129. doit_cli/templates/vscode-settings.json +14 -0
  130. doit_toolkit_cli-0.1.9.dist-info/METADATA +324 -0
  131. doit_toolkit_cli-0.1.9.dist-info/RECORD +134 -0
  132. doit_toolkit_cli-0.1.9.dist-info/WHEEL +4 -0
  133. doit_toolkit_cli-0.1.9.dist-info/entry_points.txt +2 -0
  134. doit_toolkit_cli-0.1.9.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,192 @@
1
+ """Service for GitHub issue operations via gh CLI.
2
+
3
+ This module provides the GitHubService class for interacting
4
+ with GitHub issues using the gh CLI tool.
5
+ """
6
+
7
+ import json
8
+ import subprocess
9
+ from typing import Optional
10
+
11
+ from ..models.fixit_models import GitHubIssue
12
+
13
+
14
+ class GitHubServiceError(Exception):
15
+ """Error raised when GitHub operations fail."""
16
+
17
+ pass
18
+
19
+
20
+ class GitHubService:
21
+ """Manages GitHub issue operations via gh CLI."""
22
+
23
+ def __init__(self):
24
+ """Initialize the GitHub service."""
25
+ self._verify_gh_cli()
26
+
27
+ def _verify_gh_cli(self) -> None:
28
+ """Verify that gh CLI is available and authenticated."""
29
+ try:
30
+ result = subprocess.run(
31
+ ["gh", "auth", "status"],
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ if result.returncode != 0:
36
+ # gh CLI not authenticated, but might still work for public repos
37
+ pass
38
+ except FileNotFoundError:
39
+ raise GitHubServiceError(
40
+ "GitHub CLI (gh) not found. Install it from https://cli.github.com/"
41
+ )
42
+
43
+ def is_available(self) -> bool:
44
+ """Check if GitHub API is available."""
45
+ try:
46
+ result = subprocess.run(
47
+ ["gh", "api", "rate_limit"],
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=5,
51
+ )
52
+ return result.returncode == 0
53
+ except (subprocess.TimeoutExpired, FileNotFoundError):
54
+ return False
55
+
56
+ def get_issue(self, issue_id: int) -> Optional[GitHubIssue]:
57
+ """Fetch a GitHub issue by ID.
58
+
59
+ Args:
60
+ issue_id: The GitHub issue number.
61
+
62
+ Returns:
63
+ GitHubIssue if found, None otherwise.
64
+ """
65
+ result = subprocess.run(
66
+ [
67
+ "gh", "issue", "view", str(issue_id),
68
+ "--json", "number,title,body,state,labels"
69
+ ],
70
+ capture_output=True,
71
+ text=True,
72
+ )
73
+ if result.returncode != 0:
74
+ return None
75
+
76
+ try:
77
+ data = json.loads(result.stdout)
78
+ return GitHubIssue.from_dict(data)
79
+ except (json.JSONDecodeError, KeyError):
80
+ return None
81
+
82
+ def list_bugs(self, label: str = "bug", limit: int = 20) -> list[GitHubIssue]:
83
+ """List open issues with specified label.
84
+
85
+ Args:
86
+ label: Label to filter by (default: "bug").
87
+ limit: Maximum number of issues to return.
88
+
89
+ Returns:
90
+ List of GitHubIssue objects.
91
+ """
92
+ result = subprocess.run(
93
+ [
94
+ "gh", "issue", "list",
95
+ "--label", label,
96
+ "--state", "open",
97
+ "--limit", str(limit),
98
+ "--json", "number,title,body,state,labels"
99
+ ],
100
+ capture_output=True,
101
+ text=True,
102
+ )
103
+ if result.returncode != 0:
104
+ return []
105
+
106
+ try:
107
+ data = json.loads(result.stdout)
108
+ return [GitHubIssue.from_dict(item) for item in data]
109
+ except (json.JSONDecodeError, KeyError):
110
+ return []
111
+
112
+ def close_issue(self, issue_id: int, comment: str = "") -> bool:
113
+ """Close a GitHub issue.
114
+
115
+ Args:
116
+ issue_id: The GitHub issue number.
117
+ comment: Optional comment to add when closing.
118
+
119
+ Returns:
120
+ True if successful, False otherwise.
121
+ """
122
+ cmd = ["gh", "issue", "close", str(issue_id)]
123
+ if comment:
124
+ cmd.extend(["--comment", comment])
125
+
126
+ result = subprocess.run(cmd, capture_output=True)
127
+ return result.returncode == 0
128
+
129
+ def add_comment(self, issue_id: int, comment: str) -> bool:
130
+ """Add a comment to a GitHub issue.
131
+
132
+ Args:
133
+ issue_id: The GitHub issue number.
134
+ comment: The comment text.
135
+
136
+ Returns:
137
+ True if successful, False otherwise.
138
+ """
139
+ result = subprocess.run(
140
+ ["gh", "issue", "comment", str(issue_id), "--body", comment],
141
+ capture_output=True,
142
+ )
143
+ return result.returncode == 0
144
+
145
+ def check_branch_exists(self, branch_name: str) -> tuple[bool, bool]:
146
+ """Check if a branch exists locally or remotely.
147
+
148
+ Args:
149
+ branch_name: The branch name to check.
150
+
151
+ Returns:
152
+ Tuple of (local_exists, remote_exists).
153
+ """
154
+ # Check local
155
+ local_result = subprocess.run(
156
+ ["git", "show-ref", "--verify", f"refs/heads/{branch_name}"],
157
+ capture_output=True,
158
+ )
159
+ local_exists = local_result.returncode == 0
160
+
161
+ # Check remote
162
+ remote_result = subprocess.run(
163
+ ["git", "ls-remote", "--heads", "origin", branch_name],
164
+ capture_output=True,
165
+ text=True,
166
+ )
167
+ remote_exists = bool(remote_result.stdout.strip())
168
+
169
+ return local_exists, remote_exists
170
+
171
+ def create_branch(self, branch_name: str, from_branch: str = "main") -> bool:
172
+ """Create a new git branch.
173
+
174
+ Args:
175
+ branch_name: Name of the new branch.
176
+ from_branch: Base branch to create from.
177
+
178
+ Returns:
179
+ True if successful, False otherwise.
180
+ """
181
+ # First try to checkout the base branch
182
+ subprocess.run(
183
+ ["git", "checkout", from_branch],
184
+ capture_output=True,
185
+ )
186
+
187
+ # Create and checkout the new branch
188
+ result = subprocess.run(
189
+ ["git", "checkout", "-b", branch_name],
190
+ capture_output=True,
191
+ )
192
+ return result.returncode == 0
@@ -0,0 +1,258 @@
1
+ """Hook manager service for installing and managing Git hooks."""
2
+
3
+ import json
4
+ import shutil
5
+ import stat
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from importlib import resources
11
+
12
+
13
+ class HookManager:
14
+ """Manages Git hook installation, backup, and restoration."""
15
+
16
+ HOOK_NAMES = ["pre-commit", "pre-push", "post-commit", "post-merge"]
17
+ BACKUP_DIR = ".doit/backups/hooks"
18
+ MANIFEST_FILE = "manifest.json"
19
+
20
+ def __init__(self, project_root: Optional[Path] = None):
21
+ """Initialize the hook manager.
22
+
23
+ Args:
24
+ project_root: Root directory of the project. Defaults to current directory.
25
+ """
26
+ self.project_root = project_root or Path.cwd()
27
+ self.git_hooks_dir = self.project_root / ".git" / "hooks"
28
+ self.backup_dir = self.project_root / self.BACKUP_DIR
29
+
30
+ def is_git_repo(self) -> bool:
31
+ """Check if the project is a Git repository."""
32
+ return (self.project_root / ".git").is_dir()
33
+
34
+ def get_template_path(self, hook_name: str) -> Optional[Path]:
35
+ """Get the path to a hook template.
36
+
37
+ Args:
38
+ hook_name: Name of the hook (e.g., 'pre-commit').
39
+
40
+ Returns:
41
+ Path to the template file, or None if not found.
42
+ """
43
+ # Try to get from package resources
44
+ try:
45
+ with resources.files("doit_cli.templates.hooks") as templates_dir:
46
+ template_path = Path(templates_dir) / f"{hook_name}.sh"
47
+ if template_path.exists():
48
+ return template_path
49
+ except (TypeError, FileNotFoundError):
50
+ pass
51
+
52
+ # Fallback: try relative path from this file
53
+ fallback_path = (
54
+ Path(__file__).parent.parent / "templates" / "hooks" / f"{hook_name}.sh"
55
+ )
56
+ if fallback_path.exists():
57
+ return fallback_path
58
+
59
+ return None
60
+
61
+ def install_hooks(
62
+ self, backup: bool = False, force: bool = False
63
+ ) -> tuple[list[str], list[str]]:
64
+ """Install Git hooks.
65
+
66
+ Args:
67
+ backup: Whether to backup existing hooks before installing.
68
+ force: Whether to overwrite existing hooks without prompting.
69
+
70
+ Returns:
71
+ Tuple of (installed hooks, skipped hooks).
72
+
73
+ Raises:
74
+ RuntimeError: If not in a Git repository.
75
+ """
76
+ if not self.is_git_repo():
77
+ raise RuntimeError("Not a Git repository. Run 'git init' first.")
78
+
79
+ # Ensure hooks directory exists
80
+ self.git_hooks_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ installed = []
83
+ skipped = []
84
+
85
+ for hook_name in self.HOOK_NAMES:
86
+ template_path = self.get_template_path(hook_name)
87
+ if template_path is None:
88
+ continue
89
+
90
+ hook_path = self.git_hooks_dir / hook_name
91
+
92
+ # Check if hook already exists
93
+ if hook_path.exists():
94
+ if backup:
95
+ self._backup_hook(hook_name)
96
+ elif not force:
97
+ skipped.append(hook_name)
98
+ continue
99
+
100
+ # Copy template to hooks directory
101
+ shutil.copy2(template_path, hook_path)
102
+
103
+ # Make executable
104
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
105
+
106
+ installed.append(hook_name)
107
+
108
+ return installed, skipped
109
+
110
+ def uninstall_hooks(self) -> list[str]:
111
+ """Remove installed Git hooks.
112
+
113
+ Returns:
114
+ List of removed hook names.
115
+ """
116
+ removed = []
117
+
118
+ for hook_name in self.HOOK_NAMES:
119
+ hook_path = self.git_hooks_dir / hook_name
120
+ if hook_path.exists():
121
+ # Check if it's our hook by looking for the marker comment
122
+ content = hook_path.read_text()
123
+ if "installed by doit" in content.lower():
124
+ hook_path.unlink()
125
+ removed.append(hook_name)
126
+
127
+ return removed
128
+
129
+ def _backup_hook(self, hook_name: str) -> Optional[Path]:
130
+ """Backup an existing hook.
131
+
132
+ Args:
133
+ hook_name: Name of the hook to backup.
134
+
135
+ Returns:
136
+ Path to the backup file, or None if hook doesn't exist.
137
+ """
138
+ hook_path = self.git_hooks_dir / hook_name
139
+ if not hook_path.exists():
140
+ return None
141
+
142
+ # Create backup directory
143
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
144
+
145
+ # Generate timestamp for backup filename
146
+ timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
147
+ backup_name = f"{hook_name}.{timestamp}.bak"
148
+ backup_path = self.backup_dir / backup_name
149
+
150
+ # Copy the hook
151
+ shutil.copy2(hook_path, backup_path)
152
+
153
+ # Update manifest
154
+ self._update_manifest(hook_name, timestamp)
155
+
156
+ return backup_path
157
+
158
+ def _update_manifest(self, hook_name: str, timestamp: str) -> None:
159
+ """Update the backup manifest file.
160
+
161
+ Args:
162
+ hook_name: Name of the backed up hook.
163
+ timestamp: Timestamp of the backup.
164
+ """
165
+ manifest_path = self.backup_dir / self.MANIFEST_FILE
166
+
167
+ if manifest_path.exists():
168
+ with open(manifest_path, encoding="utf-8") as f:
169
+ manifest = json.load(f)
170
+ else:
171
+ manifest = {"latest_backup": None, "backups": []}
172
+
173
+ # Add new backup entry
174
+ manifest["latest_backup"] = timestamp
175
+
176
+ # Find or create entry for this timestamp
177
+ existing_entry = next(
178
+ (b for b in manifest["backups"] if b["timestamp"] == timestamp),
179
+ None
180
+ )
181
+ if existing_entry:
182
+ if hook_name not in existing_entry["hooks"]:
183
+ existing_entry["hooks"].append(hook_name)
184
+ else:
185
+ manifest["backups"].append({
186
+ "timestamp": timestamp,
187
+ "hooks": [hook_name]
188
+ })
189
+
190
+ with open(manifest_path, "w", encoding="utf-8") as f:
191
+ json.dump(manifest, f, indent=2)
192
+
193
+ def restore_hooks(self, timestamp: Optional[str] = None) -> list[str]:
194
+ """Restore hooks from backup.
195
+
196
+ Args:
197
+ timestamp: Specific backup timestamp to restore. Defaults to latest.
198
+
199
+ Returns:
200
+ List of restored hook names.
201
+
202
+ Raises:
203
+ RuntimeError: If no backups exist.
204
+ """
205
+ manifest_path = self.backup_dir / self.MANIFEST_FILE
206
+
207
+ if not manifest_path.exists():
208
+ raise RuntimeError("No hook backups found.")
209
+
210
+ with open(manifest_path, encoding="utf-8") as f:
211
+ manifest = json.load(f)
212
+
213
+ if not manifest.get("backups"):
214
+ raise RuntimeError("No hook backups found.")
215
+
216
+ # Use specified timestamp or latest
217
+ target_timestamp = timestamp or manifest.get("latest_backup")
218
+ if not target_timestamp:
219
+ raise RuntimeError("No backup timestamp available.")
220
+
221
+ # Find the backup entry
222
+ backup_entry = next(
223
+ (b for b in manifest["backups"] if b["timestamp"] == target_timestamp),
224
+ None
225
+ )
226
+ if not backup_entry:
227
+ raise RuntimeError(f"No backup found for timestamp: {target_timestamp}")
228
+
229
+ restored = []
230
+ for hook_name in backup_entry["hooks"]:
231
+ backup_path = self.backup_dir / f"{hook_name}.{target_timestamp}.bak"
232
+ if backup_path.exists():
233
+ hook_path = self.git_hooks_dir / hook_name
234
+ shutil.copy2(backup_path, hook_path)
235
+ # Make executable
236
+ hook_path.chmod(
237
+ hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
238
+ )
239
+ restored.append(hook_name)
240
+
241
+ return restored
242
+
243
+ def get_installed_hooks(self) -> list[str]:
244
+ """Get list of currently installed doit hooks.
245
+
246
+ Returns:
247
+ List of installed hook names.
248
+ """
249
+ installed = []
250
+
251
+ for hook_name in self.HOOK_NAMES:
252
+ hook_path = self.git_hooks_dir / hook_name
253
+ if hook_path.exists():
254
+ content = hook_path.read_text()
255
+ if "installed by doit" in content.lower():
256
+ installed.append(hook_name)
257
+
258
+ return installed