titan-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
@@ -0,0 +1,528 @@
1
+ # plugins/titan-plugin-github/titan_plugin_github/agents/pr_agent.py
2
+ """
3
+ PRAgent - Intelligent orchestrator for git workflows.
4
+
5
+ This agent analyzes the complete context of a branch and automatically:
6
+ 1. Determines if changes need to be committed
7
+ 2. Generates appropriate commit messages
8
+ 3. Creates PR title and description following templates
9
+ """
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from titan_cli.ai.agents.base import BaseAIAgent, AgentRequest
17
+ from .config_loader import load_agent_config
18
+ from ..utils import calculate_pr_size
19
+
20
+ # Set up logger
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class PRAnalysis:
26
+ """Complete analysis result from PRAgent."""
27
+
28
+ # Commit analysis
29
+ needs_commit: bool
30
+ commit_message: Optional[str] = None
31
+ staged_files: list[str] = None
32
+
33
+ # PR analysis
34
+ pr_title: Optional[str] = None
35
+ pr_body: Optional[str] = None
36
+ pr_size: Optional[str] = None
37
+
38
+ # Metadata
39
+ total_tokens_used: int = 0
40
+ branch_commits: list[str] = None
41
+ files_changed: int = 0
42
+ lines_changed: int = 0
43
+
44
+
45
+ class PRAgent(BaseAIAgent):
46
+ """
47
+ Platform-level agent for intelligent git workflow automation.
48
+
49
+ This agent is the highest-level orchestrator that:
50
+ - Analyzes the full context of a branch
51
+ - Uses specialized agents (PRAgent) for specific tasks
52
+ - Makes intelligent decisions about what actions to take
53
+ - Generates all necessary content (commits, PRs)
54
+
55
+ Example:
56
+ ```python
57
+ # In a workflow step
58
+ pr_agent = PRAgent(ctx.ai, ctx.git, ctx.github)
59
+
60
+ analysis = pr_agent.analyze_and_plan(
61
+ head_branch="feat/new-feature",
62
+ base_branch="main"
63
+ )
64
+
65
+ if analysis.needs_commit:
66
+ # Use analysis.commit_message for commit
67
+
68
+ if analysis.pr_title:
69
+ # Use analysis.pr_title and analysis.pr_body for PR
70
+ ```
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ ai_client,
76
+ git_client,
77
+ github_client=None
78
+ ):
79
+ """
80
+ Initialize PRAgent.
81
+
82
+ Args:
83
+ ai_client: The AIClient instance (provides AI capabilities)
84
+ git_client: Git client for repository operations
85
+ github_client: Optional GitHub client for PR operations
86
+ """
87
+ super().__init__(ai_client)
88
+ self.git = git_client
89
+ self.github = github_client
90
+
91
+ # Load configuration from TOML (once per agent instance)
92
+ self.config = load_agent_config("pr_agent")
93
+
94
+ def get_system_prompt(self) -> str:
95
+ """System prompt for platform-level orchestration (from config)."""
96
+ # Use commit system prompt from config (Pydantic provides defaults)
97
+ return self.config.commit_system_prompt
98
+
99
+ def analyze_and_plan(
100
+ self,
101
+ head_branch: str,
102
+ base_branch: Optional[str] = None,
103
+ auto_stage: bool = False
104
+ ) -> PRAnalysis:
105
+ """
106
+ Analyze the complete branch context and create an execution plan.
107
+
108
+ This is the main entry point. It:
109
+ 1. Checks repository status
110
+ 2. Determines if commit is needed
111
+ 3. Generates commit message if needed
112
+ 4. Analyzes branch for PR creation
113
+ 5. Generates PR title and description
114
+
115
+ Args:
116
+ head_branch: The branch to analyze
117
+ base_branch: Base branch for comparison (defaults to main branch)
118
+ auto_stage: Whether to analyze unstaged changes
119
+
120
+ Returns:
121
+ PRAnalysis with complete plan (gracefully handles errors)
122
+ """
123
+ base_branch = base_branch or self.git.main_branch
124
+ total_tokens = 0
125
+
126
+ # Initialize with safe defaults
127
+ needs_commit = False
128
+ commit_message = None
129
+ staged_files = []
130
+ pr_title = None
131
+ pr_body = None
132
+ pr_size = None
133
+ files_changed = 0
134
+ lines_changed = 0
135
+ commits = []
136
+
137
+ # 1. Check if we need to commit (with error handling)
138
+ try:
139
+ status = self.git.get_status()
140
+ needs_commit = not status.is_clean
141
+
142
+ if needs_commit:
143
+ # Get unstaged/staged changes
144
+ try:
145
+ if auto_stage:
146
+ # Get modified files diff
147
+ diff = self.git.get_unstaged_diff()
148
+
149
+ # Also include untracked files if they exist
150
+ if status.untracked_files:
151
+ # Add header for untracked files context
152
+ untracked_info = "\n\n# New untracked files:\n"
153
+ for file in status.untracked_files:
154
+ untracked_info += f"# - {file}\n"
155
+ diff = diff + untracked_info if diff else untracked_info
156
+ else:
157
+ diff = self.git.get_staged_diff()
158
+
159
+ if diff:
160
+ # Generate commit message (with AI error handling)
161
+ try:
162
+ commit_result = self._generate_commit_message(diff)
163
+ commit_message = commit_result.message
164
+ total_tokens += commit_result.tokens_used
165
+ except Exception as e:
166
+ logger.warning(f"Failed to generate commit message: {e}")
167
+ commit_message = None
168
+
169
+ # Get staged files
170
+ if status.staged_files:
171
+ staged_files = status.staged_files
172
+
173
+ except Exception as e:
174
+ logger.error(f"Failed to get git diff: {e}")
175
+ # Continue with PR analysis even if commit analysis failed
176
+
177
+ except Exception as e:
178
+ logger.error(f"Failed to get git status: {e}")
179
+ # Continue with graceful fallback
180
+
181
+ # 2. Analyze branch for PR (with error handling)
182
+ try:
183
+ commits = self.git.get_branch_commits(base_branch, head_branch)
184
+ branch_diff = self.git.get_branch_diff(base_branch, head_branch)
185
+
186
+ if branch_diff and commits:
187
+ # Read PR template (uses embedded default if file not found)
188
+ template = self._read_pr_template()
189
+
190
+ # Generate PR description (with AI error handling)
191
+ try:
192
+ pr_result = self._generate_pr_description(
193
+ commits=commits,
194
+ diff=branch_diff,
195
+ head_branch=head_branch,
196
+ base_branch=base_branch,
197
+ template=template
198
+ )
199
+
200
+ pr_title = pr_result["title"]
201
+ pr_body = pr_result["body"]
202
+ pr_size = pr_result["pr_size"]
203
+ files_changed = pr_result["files_changed"]
204
+ lines_changed = pr_result["lines_changed"]
205
+ total_tokens += pr_result["tokens_used"]
206
+
207
+ except Exception as e:
208
+ logger.error(f"Failed to generate PR description: {e}")
209
+ # Return analysis without PR data
210
+
211
+ except Exception as e:
212
+ logger.error(f"Failed to analyze branch for PR: {e}")
213
+ # Return analysis without PR data
214
+
215
+ return PRAnalysis(
216
+ needs_commit=needs_commit,
217
+ commit_message=commit_message,
218
+ staged_files=staged_files,
219
+ pr_title=pr_title,
220
+ pr_body=pr_body,
221
+ pr_size=pr_size,
222
+ total_tokens_used=total_tokens,
223
+ branch_commits=commits,
224
+ files_changed=files_changed,
225
+ lines_changed=lines_changed
226
+ )
227
+
228
+ def _generate_commit_message(self, diff: str) -> "CommitMessageResult":
229
+ """
230
+ Generate a commit message from a diff (using config).
231
+
232
+ Args:
233
+ diff: The git diff to analyze
234
+
235
+ Returns:
236
+ CommitMessageResult with message and tokens
237
+
238
+ Raises:
239
+ ValueError: If diff is empty or AI response is invalid
240
+ Exception: If AI generation fails
241
+ """
242
+ if not diff or not diff.strip():
243
+ raise ValueError("Cannot generate commit message from empty diff")
244
+
245
+ # Truncate diff if too large (from config)
246
+ max_diff = self.config.max_diff_size
247
+ diff_preview = diff[:max_diff]
248
+ if len(diff) > max_diff:
249
+ diff_preview += "\n\n... (diff truncated)"
250
+
251
+ prompt = f"""Analyze this diff and generate a conventional commit message.
252
+
253
+ ```diff
254
+ {diff_preview}
255
+ ```
256
+
257
+ Format your response EXACTLY like this:
258
+ COMMIT_MESSAGE: <conventional commit message>"""
259
+
260
+ request = AgentRequest(
261
+ context=prompt,
262
+ max_tokens=500, # Increased from 200 to handle larger diffs
263
+ system_prompt=self.config.commit_system_prompt # Use specific commit prompt
264
+ )
265
+
266
+ try:
267
+ response = self.generate(request)
268
+ except Exception as e:
269
+ logger.error(f"AI generation failed for commit message: {e}")
270
+ raise
271
+
272
+ # Parse response
273
+ message = response.content.replace("COMMIT_MESSAGE:", "").strip()
274
+ message = message.strip('"').strip("'")
275
+
276
+ # Validate message
277
+ if not message or len(message.strip()) < 3:
278
+ raise ValueError("AI generated invalid or empty commit message")
279
+
280
+ # Truncate if too long
281
+ if len(message) > 72:
282
+ message = message[:69] + "..."
283
+
284
+ return CommitMessageResult(
285
+ message=message,
286
+ tokens_used=response.tokens_used
287
+ )
288
+
289
+ def _generate_pr_description(
290
+ self,
291
+ commits: list[str],
292
+ diff: str,
293
+ head_branch: str,
294
+ base_branch: str,
295
+ template: Optional[str]
296
+ ) -> dict:
297
+ """
298
+ Generate PR title and description using AI.
299
+
300
+ Args:
301
+ commits: List of commit messages
302
+ diff: Full branch diff
303
+ head_branch: Head branch name
304
+ base_branch: Base branch name
305
+ template: Optional PR template
306
+
307
+ Returns:
308
+ Dict with keys: title, body, pr_size, files_changed, lines_changed, tokens_used
309
+
310
+ Raises:
311
+ ValueError: If commits/diff are empty or AI response is invalid
312
+ Exception: If AI generation fails
313
+ """
314
+ # Validate inputs
315
+ if not commits:
316
+ raise ValueError("Cannot generate PR description without commits")
317
+ if not diff or not diff.strip():
318
+ raise ValueError("Cannot generate PR description from empty diff")
319
+
320
+ # Calculate PR size
321
+ estimation = calculate_pr_size(diff)
322
+ pr_size = estimation.pr_size
323
+ max_chars = estimation.max_chars
324
+ files_changed = estimation.files_changed
325
+ lines_changed = estimation.diff_lines
326
+
327
+ # Build prompt
328
+ prompt = self._build_pr_prompt(
329
+ commits=commits,
330
+ diff=diff,
331
+ head_branch=head_branch,
332
+ base_branch=base_branch,
333
+ template=template,
334
+ pr_size=pr_size,
335
+ max_chars=max_chars
336
+ )
337
+
338
+ # Calculate max_tokens for OUTPUT (PR description generation)
339
+ # max_tokens controls the response length, not input length
340
+ # Scale based on PR size to provide appropriate detail level
341
+ if pr_size == "small":
342
+ max_tokens = 1500 # Brief summary + key changes
343
+ elif pr_size == "medium":
344
+ max_tokens = 3000 # Moderate detail
345
+ elif pr_size == "large":
346
+ max_tokens = 5000 # Comprehensive overview
347
+ else: # very large
348
+ max_tokens = 8000 # Full context + migration notes
349
+
350
+ # Add buffer for title + formatting overhead
351
+ max_tokens = min(max_tokens + 500, 8000)
352
+
353
+ # Generate with AI
354
+ request = AgentRequest(
355
+ context=prompt,
356
+ max_tokens=max_tokens,
357
+ system_prompt=self.config.pr_system_prompt
358
+ )
359
+
360
+ try:
361
+ response = self.generate(request)
362
+ except Exception as e:
363
+ logger.error(f"AI generation failed for PR description: {e}")
364
+ raise
365
+
366
+ # Parse response
367
+ title, body = self._parse_pr_response(response.content, max_chars)
368
+
369
+ return {
370
+ "title": title,
371
+ "body": body,
372
+ "pr_size": pr_size,
373
+ "files_changed": files_changed,
374
+ "lines_changed": lines_changed,
375
+ "tokens_used": response.tokens_used
376
+ }
377
+
378
+
379
+ def _build_pr_prompt(
380
+ self,
381
+ commits: list[str],
382
+ diff: str,
383
+ head_branch: str,
384
+ base_branch: str,
385
+ template: Optional[str],
386
+ pr_size: str,
387
+ max_chars: int
388
+ ) -> str:
389
+ """Build the prompt for PR generation."""
390
+ # Prepare commits text
391
+ commits_text = "\n".join([f" - {c}" for c in commits[:self.config.max_commits_to_analyze]])
392
+ if len(commits) > self.config.max_commits_to_analyze:
393
+ commits_text += f"\n ... and {len(commits) - self.config.max_commits_to_analyze} more commits"
394
+
395
+ # Limit diff size
396
+ max_diff = self.config.max_diff_size
397
+ diff_preview = diff[:max_diff] if diff else "No diff available"
398
+ if len(diff) > max_diff:
399
+ diff_preview += "\n\n... (diff truncated for brevity)"
400
+
401
+ # Build prompt with template (always available - either from file or embedded default)
402
+ return f"""Analyze this branch and generate a professional pull request following the EXACT template structure.
403
+
404
+ ## Branch Information
405
+ - Head branch: {head_branch}
406
+ - Base branch: {base_branch}
407
+ - Total commits: {len(commits)}
408
+
409
+ ## Commits in Branch
410
+ {commits_text}
411
+
412
+ ## Branch Diff Preview
413
+ ```diff
414
+ {diff_preview}
415
+ ```
416
+
417
+ ## PR Template (MUST FOLLOW THIS STRUCTURE)
418
+ ```markdown
419
+ {template}
420
+ ```
421
+
422
+ ## CRITICAL Instructions
423
+ 1. **Title**: Follow conventional commits (type(scope): description), be clear and descriptive
424
+ - Examples: "feat(auth): add OAuth2 integration with Google provider", "fix(api): resolve race condition in cache invalidation"
425
+
426
+ 2. **Description**: MUST follow the template structure above but keep it under {max_chars} characters total
427
+ - Fill in the template sections (Summary, Type of Change, Changes Made, etc.)
428
+ - Mark checkboxes appropriately with [x]
429
+ - Adjust detail level based on PR size ({pr_size}):
430
+ * Small PRs: Brief, 1-2 lines per section
431
+ * Medium PRs: Moderate detail, 2-3 lines per section
432
+ * Large PRs: Comprehensive, 3-5 lines per section with examples
433
+ * Very Large PRs: Detailed architecture explanations, migration guides
434
+ - Total description length MUST be ≤{max_chars} chars
435
+
436
+ Format your response EXACTLY like this:
437
+ TITLE: <conventional commit title>
438
+
439
+ DESCRIPTION:
440
+ <template-based description - MAX {max_chars} chars total>"""
441
+
442
+ def _parse_pr_response(self, content: str, max_chars: int) -> tuple[str, str]:
443
+ """
444
+ Parse AI response to extract title and description.
445
+
446
+ Returns:
447
+ Tuple of (title, description)
448
+ """
449
+ if "TITLE:" not in content or "DESCRIPTION:" not in content:
450
+ raise ValueError(
451
+ f"AI response format incorrect. Expected 'TITLE:' and 'DESCRIPTION:' sections.\n"
452
+ f"Got: {content[:200]}..."
453
+ )
454
+
455
+ # Extract title and description
456
+ parts = content.split("DESCRIPTION:", 1)
457
+ title = parts[0].replace("TITLE:", "").strip()
458
+ description = parts[1].strip() if len(parts) > 1 else ""
459
+
460
+ # Clean up title
461
+ title = title.strip('"').strip("'")
462
+
463
+ # Truncate description if needed (but not title)
464
+ if len(description) > max_chars:
465
+ description = description[:max_chars - 3] + "..."
466
+
467
+ # Validate description
468
+ if not description or len(description.strip()) < 10:
469
+ raise ValueError("AI generated an empty or incomplete PR description")
470
+
471
+ return title, description
472
+
473
+ def _read_pr_template(self) -> str:
474
+ """
475
+ Read PR template if configured, otherwise use embedded default.
476
+
477
+ Only reads from file if pr_template_path is explicitly configured.
478
+ If not configured or file doesn't exist, uses embedded default template.
479
+
480
+ Returns:
481
+ Template content (never None - uses embedded default if needed)
482
+ """
483
+ # Check if template path is configured
484
+ if self.github and hasattr(self.github, 'config'):
485
+ config_path = self.github.config.pr_template_path
486
+ if config_path: # Template path is configured
487
+ path = Path(config_path)
488
+ if path.exists():
489
+ try:
490
+ with open(path, "r") as f:
491
+ return f.read()
492
+ except Exception:
493
+ pass # Fall through to default template
494
+
495
+ # No template configured or file not found - use embedded default
496
+ return self._get_default_template()
497
+
498
+ def _get_default_template(self) -> str:
499
+ """
500
+ Get the default PR template when no file exists.
501
+
502
+ This template provides a structured format that adapts to PR size
503
+ and encourages comprehensive documentation.
504
+
505
+ Returns:
506
+ Default markdown template
507
+ """
508
+ return """## Summary
509
+ Brief overview of what changed and why.
510
+
511
+ ## Changes Made
512
+ - Key change 1
513
+ - Key change 2
514
+ - Key change 3
515
+
516
+ ## Testing
517
+ How to verify these changes work.
518
+
519
+ ## Additional Notes
520
+ Any additional context, screenshots, or breaking changes.
521
+ """
522
+
523
+
524
+ @dataclass
525
+ class CommitMessageResult:
526
+ """Result from commit message generation."""
527
+ message: str
528
+ tokens_used: int
@@ -0,0 +1,8 @@
1
+ # plugins/titan-plugin-github/titan_plugin_github/clients/__init__.py
2
+ """
3
+ GitHub API clients
4
+ """
5
+
6
+ from .github_client import GitHubClient
7
+
8
+ __all__ = ["GitHubClient"]