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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- 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
|