gitwit 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.
- gitwit/__init__.py +4 -0
- gitwit/ai.py +324 -0
- gitwit/cli.py +520 -0
- gitwit/config.py +169 -0
- gitwit/git.py +312 -0
- gitwit/license.py +124 -0
- gitwit/prompts.py +161 -0
- gitwit-0.1.0.dist-info/METADATA +201 -0
- gitwit-0.1.0.dist-info/RECORD +12 -0
- gitwit-0.1.0.dist-info/WHEEL +4 -0
- gitwit-0.1.0.dist-info/entry_points.txt +2 -0
- gitwit-0.1.0.dist-info/licenses/LICENSE +21 -0
gitwit/git.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Git operations for GitWit."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitError(Exception):
|
|
9
|
+
"""Exception raised for git-related errors."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotAGitRepoError(GitError):
|
|
14
|
+
"""Exception raised when not in a git repository."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoStagedChangesError(GitError):
|
|
19
|
+
"""Exception raised when there are no staged changes."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DiffStats:
|
|
25
|
+
"""Statistics about a git diff."""
|
|
26
|
+
files_changed: list[str]
|
|
27
|
+
additions: int
|
|
28
|
+
deletions: int
|
|
29
|
+
total_lines: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_git_command(args: list[str], cwd: Path | None = None) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Run a git command and return its output.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
args: List of arguments to pass to git.
|
|
38
|
+
cwd: Working directory for the command.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The command output as a string.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
GitError: If the command fails.
|
|
45
|
+
NotAGitRepoError: If not in a git repository.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git"] + args,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
timeout=30,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
stderr = result.stderr.strip()
|
|
58
|
+
if "not a git repository" in stderr.lower():
|
|
59
|
+
raise NotAGitRepoError("Not a git repository. Run 'git init' first.")
|
|
60
|
+
raise GitError(f"Git command failed: {stderr}")
|
|
61
|
+
|
|
62
|
+
return result.stdout
|
|
63
|
+
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
raise GitError("Git command timed out")
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
raise GitError("Git is not installed or not in PATH")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_git_repo(path: Path | None = None) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Check if the current directory is a git repository.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: Path to check. Defaults to current directory.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if in a git repository, False otherwise.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
run_git_command(["rev-parse", "--git-dir"], cwd=path)
|
|
82
|
+
return True
|
|
83
|
+
except GitError:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_repo_root(path: Path | None = None) -> Path:
|
|
88
|
+
"""
|
|
89
|
+
Get the root directory of the git repository.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
path: Path to start from. Defaults to current directory.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Path to the repository root.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
NotAGitRepoError: If not in a git repository.
|
|
99
|
+
"""
|
|
100
|
+
output = run_git_command(["rev-parse", "--show-toplevel"], cwd=path)
|
|
101
|
+
return Path(output.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_staged_diff(path: Path | None = None) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Get the diff of staged changes.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
path: Repository path. Defaults to current directory.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The staged diff as a string.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
NoStagedChangesError: If there are no staged changes.
|
|
116
|
+
"""
|
|
117
|
+
diff = run_git_command(["diff", "--cached"], cwd=path)
|
|
118
|
+
|
|
119
|
+
if not diff.strip():
|
|
120
|
+
raise NoStagedChangesError(
|
|
121
|
+
"No staged changes. Stage changes with 'git add' first, "
|
|
122
|
+
"or use 'gitwit commit --all' to stage all changes."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return diff
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_staged_files(path: Path | None = None) -> list[str]:
|
|
129
|
+
"""
|
|
130
|
+
Get a list of staged files.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
path: Repository path. Defaults to current directory.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of staged file paths.
|
|
137
|
+
"""
|
|
138
|
+
output = run_git_command(["diff", "--cached", "--name-only"], cwd=path)
|
|
139
|
+
return [f for f in output.strip().split("\n") if f]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def has_staged_changes(path: Path | None = None) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Check if there are any staged changes.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
path: Repository path. Defaults to current directory.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if there are staged changes, False otherwise.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
get_staged_diff(path)
|
|
154
|
+
return True
|
|
155
|
+
except NoStagedChangesError:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def stage_all_changes(path: Path | None = None) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Stage all changes in the repository.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
path: Repository path. Defaults to current directory.
|
|
165
|
+
"""
|
|
166
|
+
run_git_command(["add", "-A"], cwd=path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def commit(message: str, path: Path | None = None) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Create a commit with the given message.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
message: Commit message.
|
|
175
|
+
path: Repository path. Defaults to current directory.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The commit output.
|
|
179
|
+
"""
|
|
180
|
+
return run_git_command(["commit", "-m", message], cwd=path)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_branch_name(path: Path | None = None) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Get the current branch name.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
path: Repository path. Defaults to current directory.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The current branch name.
|
|
192
|
+
"""
|
|
193
|
+
output = run_git_command(["branch", "--show-current"], cwd=path)
|
|
194
|
+
return output.strip()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_branch_diff(base_branch: str = "main", path: Path | None = None) -> str:
|
|
198
|
+
"""
|
|
199
|
+
Get the diff between the current branch and a base branch.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
base_branch: The base branch to compare against.
|
|
203
|
+
path: Repository path. Defaults to current directory.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The diff as a string.
|
|
207
|
+
"""
|
|
208
|
+
# Try the specified branch, fall back to 'master' if 'main' doesn't exist
|
|
209
|
+
try:
|
|
210
|
+
return run_git_command(["diff", f"{base_branch}...HEAD"], cwd=path)
|
|
211
|
+
except GitError:
|
|
212
|
+
if base_branch == "main":
|
|
213
|
+
return run_git_command(["diff", "master...HEAD"], cwd=path)
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_diff_stats(diff: str) -> DiffStats:
|
|
218
|
+
"""
|
|
219
|
+
Parse a diff string and extract statistics.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
diff: The git diff string.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
DiffStats with information about the changes.
|
|
226
|
+
"""
|
|
227
|
+
lines = diff.split('\n')
|
|
228
|
+
files = []
|
|
229
|
+
additions = 0
|
|
230
|
+
deletions = 0
|
|
231
|
+
|
|
232
|
+
for line in lines:
|
|
233
|
+
if line.startswith('diff --git'):
|
|
234
|
+
# Extract filename from "diff --git a/path b/path"
|
|
235
|
+
parts = line.split(' ')
|
|
236
|
+
if len(parts) >= 4:
|
|
237
|
+
files.append(parts[2][2:]) # Remove 'a/' prefix
|
|
238
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
239
|
+
additions += 1
|
|
240
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
241
|
+
deletions += 1
|
|
242
|
+
|
|
243
|
+
return DiffStats(
|
|
244
|
+
files_changed=files,
|
|
245
|
+
additions=additions,
|
|
246
|
+
deletions=deletions,
|
|
247
|
+
total_lines=len(lines)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_current_branch(path: Path | None = None) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Get the current branch name.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
path: Repository path. Defaults to current directory.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The current branch name.
|
|
260
|
+
"""
|
|
261
|
+
return get_branch_name(path)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_default_branch(path: Path | None = None) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Detect the default branch (main or master).
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
path: Repository path. Defaults to current directory.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
'main' if it exists, otherwise 'master'.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
GitError: If neither main nor master exists.
|
|
276
|
+
"""
|
|
277
|
+
# Check if 'main' branch exists
|
|
278
|
+
try:
|
|
279
|
+
run_git_command(["rev-parse", "--verify", "main"], cwd=path)
|
|
280
|
+
return "main"
|
|
281
|
+
except GitError:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
# Check if 'master' branch exists
|
|
285
|
+
try:
|
|
286
|
+
run_git_command(["rev-parse", "--verify", "master"], cwd=path)
|
|
287
|
+
return "master"
|
|
288
|
+
except GitError:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
raise GitError("Could not find default branch (main or master)")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_recent_commits(count: int = 5, path: Path | None = None) -> list[str]:
|
|
295
|
+
"""
|
|
296
|
+
Get recent commit messages for style reference.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
count: Number of commits to retrieve.
|
|
300
|
+
path: Repository path. Defaults to current directory.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
List of recent commit messages.
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
output = run_git_command(
|
|
307
|
+
["log", f"-{count}", "--pretty=format:%s"],
|
|
308
|
+
cwd=path
|
|
309
|
+
)
|
|
310
|
+
return [msg for msg in output.strip().split("\n") if msg]
|
|
311
|
+
except GitError:
|
|
312
|
+
return []
|
gitwit/license.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""License validation for GitWit (LemonSqueezy integration stub)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class LicenseInfo:
|
|
9
|
+
"""Information about a validated license."""
|
|
10
|
+
valid: bool
|
|
11
|
+
license_key: str | None = None
|
|
12
|
+
email: str | None = None
|
|
13
|
+
plan: str = "free" # free, pro, team
|
|
14
|
+
features: list[str] | None = None
|
|
15
|
+
|
|
16
|
+
def __post_init__(self):
|
|
17
|
+
if self.features is None:
|
|
18
|
+
self.features = self._get_plan_features()
|
|
19
|
+
|
|
20
|
+
def _get_plan_features(self) -> list[str]:
|
|
21
|
+
"""Get features for the current plan."""
|
|
22
|
+
free_features = ["commit"]
|
|
23
|
+
pro_features = free_features + ["pr", "changelog", "release", "custom-prompts"]
|
|
24
|
+
team_features = pro_features + ["team-config", "priority-support"]
|
|
25
|
+
|
|
26
|
+
if self.plan == "team":
|
|
27
|
+
return team_features
|
|
28
|
+
elif self.plan == "pro":
|
|
29
|
+
return pro_features
|
|
30
|
+
return free_features
|
|
31
|
+
|
|
32
|
+
def has_feature(self, feature: str) -> bool:
|
|
33
|
+
"""Check if a feature is available in the current plan."""
|
|
34
|
+
return feature in (self.features or [])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Global license state
|
|
38
|
+
_current_license: LicenseInfo | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_license() -> LicenseInfo:
|
|
42
|
+
"""
|
|
43
|
+
Get the current license information.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
LicenseInfo with current license status.
|
|
47
|
+
"""
|
|
48
|
+
global _current_license
|
|
49
|
+
|
|
50
|
+
if _current_license is None:
|
|
51
|
+
# For MVP, return free license
|
|
52
|
+
# TODO: Implement LemonSqueezy validation
|
|
53
|
+
_current_license = LicenseInfo(valid=True, plan="free")
|
|
54
|
+
|
|
55
|
+
return _current_license
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_license(license_key: str) -> LicenseInfo:
|
|
59
|
+
"""
|
|
60
|
+
Validate a license key with LemonSqueezy.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
license_key: The license key to validate.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
LicenseInfo with validation result.
|
|
67
|
+
|
|
68
|
+
Note:
|
|
69
|
+
This is a stub for MVP. Full implementation will call LemonSqueezy API.
|
|
70
|
+
"""
|
|
71
|
+
global _current_license
|
|
72
|
+
|
|
73
|
+
# TODO: Implement actual LemonSqueezy API validation
|
|
74
|
+
# API endpoint: https://api.lemonsqueezy.com/v1/licenses/validate
|
|
75
|
+
#
|
|
76
|
+
# For now, accept any key that looks valid (for testing)
|
|
77
|
+
if license_key and len(license_key) >= 10:
|
|
78
|
+
_current_license = LicenseInfo(
|
|
79
|
+
valid=True,
|
|
80
|
+
license_key=license_key,
|
|
81
|
+
plan="pro",
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
_current_license = LicenseInfo(valid=False, plan="free")
|
|
85
|
+
|
|
86
|
+
return _current_license
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def require_feature(feature: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Check if a feature is available, showing upgrade message if not.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
feature: The feature to check.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if feature is available, False otherwise.
|
|
98
|
+
"""
|
|
99
|
+
license_info = get_license()
|
|
100
|
+
|
|
101
|
+
if license_info.has_feature(feature):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
# Feature not available
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_upgrade_message(feature: str) -> str:
|
|
109
|
+
"""Get the upgrade message for a feature."""
|
|
110
|
+
feature_names = {
|
|
111
|
+
"pr": "PR description generation",
|
|
112
|
+
"changelog": "Changelog generation",
|
|
113
|
+
"release": "Release notes generation",
|
|
114
|
+
"custom-prompts": "Custom prompt templates",
|
|
115
|
+
"team-config": "Team configuration",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
feature_name = feature_names.get(feature, feature)
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
f"\n{feature_name} is a Pro feature.\n\n"
|
|
122
|
+
f"Upgrade to GitWit Pro: https://gitwit.dev/pricing\n"
|
|
123
|
+
f"Use code LAUNCH50 for 50% off!\n"
|
|
124
|
+
)
|
gitwit/prompts.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Prompt templates for AI operations."""
|
|
2
|
+
|
|
3
|
+
COMMIT_SYSTEM_PROMPT = """You are a git commit message generator. Generate a single conventional commit message following the format:
|
|
4
|
+
|
|
5
|
+
type(scope): description
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
|
|
9
|
+
- Scope is optional but encouraged (e.g., auth, api, ui, config)
|
|
10
|
+
- Description must be lowercase, imperative mood, no period at end
|
|
11
|
+
- Keep the first line under 72 characters
|
|
12
|
+
- Only output the commit message, nothing else
|
|
13
|
+
- No quotes around the message
|
|
14
|
+
- No explanation or preamble
|
|
15
|
+
|
|
16
|
+
Examples of good commit messages:
|
|
17
|
+
- feat(auth): add OAuth2 login support
|
|
18
|
+
- fix(api): handle null response from user endpoint
|
|
19
|
+
- docs: update installation instructions
|
|
20
|
+
- refactor(utils): simplify date parsing logic
|
|
21
|
+
- chore: update dependencies to latest versions"""
|
|
22
|
+
|
|
23
|
+
COMMIT_USER_PROMPT = """Generate a commit message for the following git diff:
|
|
24
|
+
|
|
25
|
+
{diff}"""
|
|
26
|
+
|
|
27
|
+
COMMIT_SUMMARY_USER_PROMPT = """Generate a commit message for the following changes:
|
|
28
|
+
|
|
29
|
+
Files changed: {files}
|
|
30
|
+
Total additions: +{additions}
|
|
31
|
+
Total deletions: -{deletions}
|
|
32
|
+
|
|
33
|
+
Partial diff (truncated due to size):
|
|
34
|
+
{diff}"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_commit_prompt(diff: str, max_diff_chars: int = 4000) -> tuple[str, str]:
|
|
38
|
+
"""
|
|
39
|
+
Get the system and user prompts for commit message generation.
|
|
40
|
+
|
|
41
|
+
If the diff is too long, summarizes it to fit within token limits.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
diff: The git diff string
|
|
45
|
+
max_diff_chars: Maximum characters for the diff in the prompt
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (system_prompt, user_prompt)
|
|
49
|
+
"""
|
|
50
|
+
if len(diff) <= max_diff_chars:
|
|
51
|
+
return COMMIT_SYSTEM_PROMPT, COMMIT_USER_PROMPT.format(diff=diff)
|
|
52
|
+
|
|
53
|
+
# Parse diff to get summary info
|
|
54
|
+
lines = diff.split('\n')
|
|
55
|
+
files = []
|
|
56
|
+
additions = 0
|
|
57
|
+
deletions = 0
|
|
58
|
+
|
|
59
|
+
for line in lines:
|
|
60
|
+
if line.startswith('diff --git'):
|
|
61
|
+
# Extract filename from "diff --git a/path b/path"
|
|
62
|
+
parts = line.split(' ')
|
|
63
|
+
if len(parts) >= 4:
|
|
64
|
+
files.append(parts[2][2:]) # Remove 'a/' prefix
|
|
65
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
66
|
+
additions += 1
|
|
67
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
68
|
+
deletions += 1
|
|
69
|
+
|
|
70
|
+
# Truncate diff to fit
|
|
71
|
+
truncated_diff = diff[:max_diff_chars - 500] # Leave room for summary
|
|
72
|
+
|
|
73
|
+
user_prompt = COMMIT_SUMMARY_USER_PROMPT.format(
|
|
74
|
+
files=', '.join(files[:10]) + ('...' if len(files) > 10 else ''),
|
|
75
|
+
additions=additions,
|
|
76
|
+
deletions=deletions,
|
|
77
|
+
diff=truncated_diff
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return COMMIT_SYSTEM_PROMPT, user_prompt
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# PR (Pull Request) prompts
|
|
84
|
+
|
|
85
|
+
PR_SYSTEM_PROMPT = """You are a pull request description generator. Generate a PR title and description in the following exact format:
|
|
86
|
+
|
|
87
|
+
Title: <short descriptive title under 72 characters>
|
|
88
|
+
|
|
89
|
+
## Summary
|
|
90
|
+
<1-3 sentences describing what this PR does and why>
|
|
91
|
+
|
|
92
|
+
## Changes
|
|
93
|
+
<bullet list of key changes>
|
|
94
|
+
|
|
95
|
+
Rules:
|
|
96
|
+
- Title should be concise and descriptive, no type prefix needed
|
|
97
|
+
- Summary should explain the purpose and impact
|
|
98
|
+
- Changes should list the main modifications made
|
|
99
|
+
- Keep it professional and clear
|
|
100
|
+
- No code blocks unless absolutely necessary
|
|
101
|
+
- Output ONLY the formatted content, no extra text"""
|
|
102
|
+
|
|
103
|
+
PR_USER_PROMPT = """Generate a PR title and description for the following branch diff:
|
|
104
|
+
|
|
105
|
+
Branch: {branch_name}
|
|
106
|
+
|
|
107
|
+
{diff}"""
|
|
108
|
+
|
|
109
|
+
PR_SUMMARY_USER_PROMPT = """Generate a PR title and description for the following changes:
|
|
110
|
+
|
|
111
|
+
Branch: {branch_name}
|
|
112
|
+
Files changed: {files}
|
|
113
|
+
Total additions: +{additions}
|
|
114
|
+
Total deletions: -{deletions}
|
|
115
|
+
|
|
116
|
+
Partial diff (truncated due to size):
|
|
117
|
+
{diff}"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_pr_prompt(diff: str, branch_name: str, max_diff_chars: int = 6000) -> tuple[str, str]:
|
|
121
|
+
"""
|
|
122
|
+
Get the system and user prompts for PR description generation.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
diff: The git diff string
|
|
126
|
+
branch_name: The current branch name
|
|
127
|
+
max_diff_chars: Maximum characters for the diff in the prompt
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Tuple of (system_prompt, user_prompt)
|
|
131
|
+
"""
|
|
132
|
+
if len(diff) <= max_diff_chars:
|
|
133
|
+
return PR_SYSTEM_PROMPT, PR_USER_PROMPT.format(branch_name=branch_name, diff=diff)
|
|
134
|
+
|
|
135
|
+
# Parse diff to get summary info
|
|
136
|
+
lines = diff.split('\n')
|
|
137
|
+
files = []
|
|
138
|
+
additions = 0
|
|
139
|
+
deletions = 0
|
|
140
|
+
|
|
141
|
+
for line in lines:
|
|
142
|
+
if line.startswith('diff --git'):
|
|
143
|
+
parts = line.split(' ')
|
|
144
|
+
if len(parts) >= 4:
|
|
145
|
+
files.append(parts[2][2:])
|
|
146
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
147
|
+
additions += 1
|
|
148
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
149
|
+
deletions += 1
|
|
150
|
+
|
|
151
|
+
truncated_diff = diff[:max_diff_chars - 500]
|
|
152
|
+
|
|
153
|
+
user_prompt = PR_SUMMARY_USER_PROMPT.format(
|
|
154
|
+
branch_name=branch_name,
|
|
155
|
+
files=', '.join(files[:10]) + ('...' if len(files) > 10 else ''),
|
|
156
|
+
additions=additions,
|
|
157
|
+
deletions=deletions,
|
|
158
|
+
diff=truncated_diff
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return PR_SYSTEM_PROMPT, user_prompt
|