pr-generator-agent 1.0.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.
- aipr/__init__.py +18 -0
- aipr/main.py +634 -0
- aipr/prompts/__init__.py +5 -0
- aipr/prompts/meta.xml +49 -0
- aipr/prompts/prompts.py +189 -0
- aipr/providers.py +197 -0
- pr_generator_agent-1.0.0.dist-info/METADATA +152 -0
- pr_generator_agent-1.0.0.dist-info/RECORD +11 -0
- pr_generator_agent-1.0.0.dist-info/WHEEL +4 -0
- pr_generator_agent-1.0.0.dist-info/entry_points.txt +2 -0
- pr_generator_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
aipr/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AIPR - AI-powered Merge Request Description Generator
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_version():
|
|
10
|
+
try:
|
|
11
|
+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
|
12
|
+
with open(pyproject_path, "rb") as f:
|
|
13
|
+
return tomllib.load(f)["project"]["version"]
|
|
14
|
+
except Exception:
|
|
15
|
+
return "unknown"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__version__ = get_version()
|
aipr/main.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import git
|
|
9
|
+
import tiktoken
|
|
10
|
+
|
|
11
|
+
from .prompts import InvalidPromptError, PromptManager
|
|
12
|
+
from .providers import generate_with_anthropic, generate_with_azure_openai, generate_with_openai
|
|
13
|
+
|
|
14
|
+
# ANSI color codes
|
|
15
|
+
BLUE = "\033[94m"
|
|
16
|
+
GREEN = "\033[92m"
|
|
17
|
+
YELLOW = "\033[93m"
|
|
18
|
+
RED = "\033[91m"
|
|
19
|
+
ENDC = "\033[0m"
|
|
20
|
+
BOLD = "\033[1m"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_provider_and_model(model: Optional[str]) -> Tuple[str, str]:
|
|
24
|
+
"""Detect which provider and model to use based on environment and args."""
|
|
25
|
+
if model:
|
|
26
|
+
# Handle simple aliases first
|
|
27
|
+
if model == "claude":
|
|
28
|
+
return "anthropic", "claude-3-sonnet-20240229"
|
|
29
|
+
if model == "azure":
|
|
30
|
+
return "azure", "gpt-4o-mini"
|
|
31
|
+
if model == "openai":
|
|
32
|
+
return "openai", "gpt-4o"
|
|
33
|
+
|
|
34
|
+
# Handle Azure models
|
|
35
|
+
if model.startswith("azure/"):
|
|
36
|
+
_, model_name = model.split("/", 1)
|
|
37
|
+
# Map azure model names to deployment names
|
|
38
|
+
azure_models = {
|
|
39
|
+
"o1-mini": "o1-mini",
|
|
40
|
+
"gpt-4o": "gpt-4o",
|
|
41
|
+
"gpt-4o-mini": "gpt-4o-mini",
|
|
42
|
+
"gpt-4": "gpt-4o", # Alias for gpt-4o
|
|
43
|
+
}
|
|
44
|
+
return "azure", azure_models.get(model_name, model_name)
|
|
45
|
+
|
|
46
|
+
# Handle OpenAI models
|
|
47
|
+
if model.startswith("gpt") or model == "gpt4":
|
|
48
|
+
# Only use Azure if explicitly configured with endpoint
|
|
49
|
+
if os.getenv("AZURE_OPENAI_ENDPOINT") and model.startswith("azure/"):
|
|
50
|
+
openai_models = {
|
|
51
|
+
"gpt4": "gpt-4",
|
|
52
|
+
"gpt-4-turbo": "gpt-4-turbo",
|
|
53
|
+
"gpt-4o": "gpt-4",
|
|
54
|
+
}
|
|
55
|
+
return "azure", openai_models.get(model, model)
|
|
56
|
+
else:
|
|
57
|
+
openai_models = {
|
|
58
|
+
"gpt4": "gpt-4",
|
|
59
|
+
"gpt-4-turbo": "gpt-4-turbo",
|
|
60
|
+
"gpt-4o": "gpt-4",
|
|
61
|
+
}
|
|
62
|
+
return "openai", openai_models.get(model, model)
|
|
63
|
+
|
|
64
|
+
# Handle Anthropic models
|
|
65
|
+
if model.startswith("claude"):
|
|
66
|
+
anthropic_models = {
|
|
67
|
+
"claude-3": "claude-3-opus-20240229",
|
|
68
|
+
"claude-3-opus": "claude-3-opus-20240229",
|
|
69
|
+
"claude-3.5-sonnet": "claude-3-5-sonnet-20241022",
|
|
70
|
+
"claude-3-sonnet": "claude-3-sonnet-20240229",
|
|
71
|
+
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
|
|
72
|
+
"claude-3-haiku": "claude-3-haiku-20240307",
|
|
73
|
+
}
|
|
74
|
+
return "anthropic", anthropic_models.get(model, model)
|
|
75
|
+
|
|
76
|
+
# No model specified, check environment for default
|
|
77
|
+
if os.getenv("ANTHROPIC_API_KEY"):
|
|
78
|
+
return "anthropic", "claude-3-sonnet-20240229"
|
|
79
|
+
if os.getenv("AZURE_OPENAI_ENDPOINT") and os.getenv("AZURE_API_KEY"):
|
|
80
|
+
return "azure", "gpt-4"
|
|
81
|
+
if os.getenv("OPENAI_API_KEY"):
|
|
82
|
+
return "openai", "gpt-4"
|
|
83
|
+
|
|
84
|
+
raise Exception(
|
|
85
|
+
"No API key found. Please set ANTHROPIC_API_KEY, AZURE_API_KEY, or OPENAI_API_KEY"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def count_tokens(text: str, model: str) -> int:
|
|
90
|
+
"""Count the number of tokens in a text string."""
|
|
91
|
+
try:
|
|
92
|
+
if model.startswith(("gpt-3", "gpt-4")):
|
|
93
|
+
encoding = tiktoken.encoding_for_model(model)
|
|
94
|
+
else:
|
|
95
|
+
# Default to cl100k_base for other models (including Azure and Claude)
|
|
96
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
97
|
+
return len(encoding.encode(text))
|
|
98
|
+
except Exception:
|
|
99
|
+
# If we can't get a token count, return an estimate
|
|
100
|
+
return len(text) // 4 # Rough estimate of tokens
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_token_info(user_prompt: str, system_prompt: str, verbose: bool):
|
|
104
|
+
"""Print token information for the prompts."""
|
|
105
|
+
if verbose:
|
|
106
|
+
print(f"System Prompt:\n{system_prompt}\n")
|
|
107
|
+
print(f"User Prompt:\n{user_prompt}\n")
|
|
108
|
+
# Add actual token counting if needed
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def print_separator(char="─", color=GREEN):
|
|
112
|
+
"""Print a separator line with the given character and color."""
|
|
113
|
+
terminal_width = os.get_terminal_size().columns
|
|
114
|
+
print(f"{color}{char * terminal_width}{ENDC}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def print_header(text: str, level: int = 1):
|
|
118
|
+
"""Print a header with the given text and level."""
|
|
119
|
+
if level == 1:
|
|
120
|
+
print(f"\n{text}")
|
|
121
|
+
print("=" * len(text))
|
|
122
|
+
else:
|
|
123
|
+
print(f"\n{text}")
|
|
124
|
+
print("-" * len(text))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run_trivy_scan(path: str, silent: bool = False, verbose: bool = False) -> Dict[str, Any]:
|
|
128
|
+
"""Run trivy filesystem scan and return the results as a dictionary."""
|
|
129
|
+
try:
|
|
130
|
+
# Determine project type and scanning approach
|
|
131
|
+
is_java = os.path.exists(os.path.join(path, "pom.xml"))
|
|
132
|
+
is_node = os.path.exists(os.path.join(path, "package.json"))
|
|
133
|
+
|
|
134
|
+
# Enhanced Python project detection
|
|
135
|
+
python_files = [
|
|
136
|
+
"requirements.txt",
|
|
137
|
+
"setup.py",
|
|
138
|
+
"pyproject.toml",
|
|
139
|
+
"poetry.lock",
|
|
140
|
+
"Pipfile",
|
|
141
|
+
"Pipfile.lock",
|
|
142
|
+
]
|
|
143
|
+
is_python = any(os.path.exists(os.path.join(path, f)) for f in python_files)
|
|
144
|
+
|
|
145
|
+
trivy_args = ["trivy", "fs", "--format", "json", "--scanners", "vuln,secret,config"]
|
|
146
|
+
|
|
147
|
+
# Add dependency scanning for supported project types
|
|
148
|
+
if is_java:
|
|
149
|
+
try:
|
|
150
|
+
if not silent:
|
|
151
|
+
print(
|
|
152
|
+
f"{BLUE}Detected Java project, " f"resolving Maven dependencies...{ENDC}",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
subprocess.run(
|
|
156
|
+
["mvn", "dependency:resolve", "-DskipTests"],
|
|
157
|
+
cwd=path,
|
|
158
|
+
check=True,
|
|
159
|
+
capture_output=True,
|
|
160
|
+
)
|
|
161
|
+
trivy_args.append("--dependency-tree")
|
|
162
|
+
except subprocess.CalledProcessError as e:
|
|
163
|
+
print(
|
|
164
|
+
f"{YELLOW}Warning: Maven dependency resolution failed: {e}{ENDC}",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
)
|
|
167
|
+
elif is_node:
|
|
168
|
+
if not silent:
|
|
169
|
+
print(
|
|
170
|
+
f"{BLUE}Detected Node.js project, including dependency scanning...{ENDC}",
|
|
171
|
+
file=sys.stderr,
|
|
172
|
+
)
|
|
173
|
+
trivy_args.append("--dependency-tree")
|
|
174
|
+
elif is_python:
|
|
175
|
+
if not silent:
|
|
176
|
+
print(
|
|
177
|
+
f"{BLUE}Detected Python project, including enhanced scanning...{ENDC}",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Check which package management files exist
|
|
182
|
+
pkg_files = []
|
|
183
|
+
has_poetry = os.path.exists(os.path.join(path, "poetry.lock"))
|
|
184
|
+
has_pipenv = os.path.exists(os.path.join(path, "Pipfile.lock"))
|
|
185
|
+
has_pip = os.path.exists(os.path.join(path, "requirements.txt"))
|
|
186
|
+
has_setup = os.path.exists(os.path.join(path, "setup.py"))
|
|
187
|
+
has_pyproject = os.path.exists(os.path.join(path, "pyproject.toml"))
|
|
188
|
+
|
|
189
|
+
if has_poetry:
|
|
190
|
+
pkg_files.append("poetry.lock")
|
|
191
|
+
if has_pyproject:
|
|
192
|
+
pkg_files.append("pyproject.toml")
|
|
193
|
+
if not silent:
|
|
194
|
+
print(
|
|
195
|
+
f"{BLUE}Using Poetry for dependency scanning "
|
|
196
|
+
f"(transitive dependencies, excludes dev)...{ENDC}",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
elif has_pipenv:
|
|
200
|
+
pkg_files.append("Pipfile.lock")
|
|
201
|
+
if not silent:
|
|
202
|
+
print(
|
|
203
|
+
f"{BLUE}Using Pipenv for dependency scanning "
|
|
204
|
+
f"(transitive dependencies, includes dev)...{ENDC}",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
elif has_pip:
|
|
208
|
+
pkg_files.append("requirements.txt")
|
|
209
|
+
if not silent:
|
|
210
|
+
print(
|
|
211
|
+
f"{BLUE}Using pip requirements "
|
|
212
|
+
f"(direct dependencies only, includes dev)...{ENDC}",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
elif has_setup or has_pyproject:
|
|
216
|
+
if has_setup:
|
|
217
|
+
pkg_files.append("setup.py")
|
|
218
|
+
if has_pyproject:
|
|
219
|
+
pkg_files.append("pyproject.toml")
|
|
220
|
+
if not silent:
|
|
221
|
+
print(f"{BLUE}Using Python package metadata files...{ENDC}", file=sys.stderr)
|
|
222
|
+
|
|
223
|
+
if pkg_files and not silent:
|
|
224
|
+
print(f"{BLUE}Found package files: {', '.join(pkg_files)}{ENDC}", file=sys.stderr)
|
|
225
|
+
|
|
226
|
+
# Add Python-specific scanning options
|
|
227
|
+
trivy_args.append("--dependency-tree")
|
|
228
|
+
else:
|
|
229
|
+
if not silent:
|
|
230
|
+
print(
|
|
231
|
+
f"{BLUE}No specific package manager detected, "
|
|
232
|
+
f"performing filesystem scan...{ENDC}",
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Add the path as the last argument
|
|
237
|
+
trivy_args.append(path)
|
|
238
|
+
|
|
239
|
+
if not silent and verbose:
|
|
240
|
+
print(f"{BLUE}Running trivy with args: {' '.join(trivy_args)}{ENDC}", file=sys.stderr)
|
|
241
|
+
|
|
242
|
+
# Run trivy scan
|
|
243
|
+
result = subprocess.run(trivy_args, capture_output=True, text=True, check=True)
|
|
244
|
+
return json.loads(result.stdout)
|
|
245
|
+
except subprocess.CalledProcessError as e:
|
|
246
|
+
print(f"{RED}Error running trivy scan: {e}{ENDC}", file=sys.stderr)
|
|
247
|
+
if e.stderr:
|
|
248
|
+
print(f"{RED}Trivy error details: {e.stderr}{ENDC}", file=sys.stderr)
|
|
249
|
+
return {}
|
|
250
|
+
except json.JSONDecodeError as e:
|
|
251
|
+
print(f"{RED}Error parsing trivy output: {e}{ENDC}", file=sys.stderr)
|
|
252
|
+
return {}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def compare_vulnerabilities(
|
|
256
|
+
current_scan: Dict[str, Any], target_scan: Dict[str, Any]
|
|
257
|
+
) -> Tuple[str, str]:
|
|
258
|
+
"""
|
|
259
|
+
Compare vulnerability scans between branches and return a tuple of
|
|
260
|
+
(markdown_report, analysis_data)
|
|
261
|
+
"""
|
|
262
|
+
if not current_scan or not target_scan:
|
|
263
|
+
return "Error: Unable to generate vulnerability comparison", ""
|
|
264
|
+
|
|
265
|
+
report = ["## Vulnerability Comparison\n"]
|
|
266
|
+
analysis_data = ["### Security Analysis Data\n"]
|
|
267
|
+
|
|
268
|
+
# Get vulnerabilities from both scans
|
|
269
|
+
current_vulns = []
|
|
270
|
+
target_vulns = []
|
|
271
|
+
|
|
272
|
+
def extract_vulns(scan_data: Dict[str, Any]) -> list:
|
|
273
|
+
vulns = []
|
|
274
|
+
for result in scan_data.get("Results", []):
|
|
275
|
+
target = result.get("Target", "")
|
|
276
|
+
type = result.get("Type", "")
|
|
277
|
+
for vuln in result.get("Vulnerabilities", []):
|
|
278
|
+
vulns.append(
|
|
279
|
+
{
|
|
280
|
+
"id": vuln.get("VulnerabilityID"),
|
|
281
|
+
"pkg": vuln.get("PkgName"),
|
|
282
|
+
"version": vuln.get("InstalledVersion"),
|
|
283
|
+
"severity": vuln.get("Severity"),
|
|
284
|
+
"description": vuln.get("Description"),
|
|
285
|
+
"fix_version": vuln.get("FixedVersion"),
|
|
286
|
+
"target": target,
|
|
287
|
+
"type": type,
|
|
288
|
+
"title": vuln.get("Title"),
|
|
289
|
+
"references": vuln.get("References", []),
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
return vulns
|
|
293
|
+
|
|
294
|
+
current_vulns = extract_vulns(current_scan)
|
|
295
|
+
target_vulns = extract_vulns(target_scan)
|
|
296
|
+
|
|
297
|
+
# Create unique identifiers for comparison
|
|
298
|
+
def create_vuln_key(v: Dict[str, Any]) -> str:
|
|
299
|
+
return f"{v['id']}:{v['pkg']}:{v['version']}:{v['target']}"
|
|
300
|
+
|
|
301
|
+
current_vuln_keys = {create_vuln_key(v) for v in current_vulns}
|
|
302
|
+
target_vuln_keys = {create_vuln_key(v) for v in target_vulns}
|
|
303
|
+
|
|
304
|
+
# Find new and fixed vulnerabilities
|
|
305
|
+
new_vulns = current_vuln_keys - target_vuln_keys
|
|
306
|
+
fixed_vulns = target_vuln_keys - current_vuln_keys
|
|
307
|
+
|
|
308
|
+
# Group vulnerabilities by severity for analysis
|
|
309
|
+
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "UNKNOWN": 4}
|
|
310
|
+
|
|
311
|
+
def group_by_severity(vulns_keys: set, vuln_list: list) -> Dict[str, list]:
|
|
312
|
+
grouped = {}
|
|
313
|
+
for vuln in vuln_list:
|
|
314
|
+
key = create_vuln_key(vuln)
|
|
315
|
+
if key in vulns_keys:
|
|
316
|
+
sev = vuln["severity"] or "UNKNOWN"
|
|
317
|
+
if sev not in grouped:
|
|
318
|
+
grouped[sev] = []
|
|
319
|
+
grouped[sev].append(vuln)
|
|
320
|
+
return {
|
|
321
|
+
k: grouped[k] for k in sorted(grouped.keys(), key=lambda x: severity_order.get(x, 999))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Prepare detailed analysis data
|
|
325
|
+
if new_vulns:
|
|
326
|
+
analysis_data.append("\nNew Vulnerabilities Details:")
|
|
327
|
+
grouped_new = group_by_severity(new_vulns, current_vulns)
|
|
328
|
+
for severity, vulns in grouped_new.items():
|
|
329
|
+
analysis_data.append(f"\n{severity} Severity:")
|
|
330
|
+
for vuln in sorted(vulns, key=lambda x: x["id"]):
|
|
331
|
+
analysis_data.append(f"\n- {vuln['id']} ({vuln['type']}):")
|
|
332
|
+
analysis_data.append(f" - Package: {vuln['pkg']} {vuln['version']}")
|
|
333
|
+
analysis_data.append(f" - In: {vuln['target']}")
|
|
334
|
+
analysis_data.append(f" - Title: {vuln['title']}")
|
|
335
|
+
analysis_data.append(f" - Description: {vuln['description']}")
|
|
336
|
+
if vuln["fix_version"]:
|
|
337
|
+
fix_info = f" - Fix available in version: " f"{vuln['fix_version']}"
|
|
338
|
+
analysis_data.append(fix_info)
|
|
339
|
+
if vuln["references"]:
|
|
340
|
+
analysis_data.append(" - References:")
|
|
341
|
+
for ref in vuln["references"][:3]: # Limit to first 3 references
|
|
342
|
+
analysis_data.append(f" * {ref}")
|
|
343
|
+
|
|
344
|
+
if fixed_vulns:
|
|
345
|
+
analysis_data.append("\nFixed Vulnerabilities Details:")
|
|
346
|
+
grouped_fixed = group_by_severity(fixed_vulns, target_vulns)
|
|
347
|
+
for severity, vulns in grouped_fixed.items():
|
|
348
|
+
analysis_data.append(f"\n{severity} Severity:")
|
|
349
|
+
for vuln in sorted(vulns, key=lambda x: x["id"]):
|
|
350
|
+
analysis_data.append(f"\n- {vuln['id']} ({vuln['type']}):")
|
|
351
|
+
analysis_data.append(f" - Package: {vuln['pkg']} {vuln['version']}")
|
|
352
|
+
analysis_data.append(f" - In: {vuln['target']}")
|
|
353
|
+
analysis_data.append(f" - Title: {vuln['title']}")
|
|
354
|
+
|
|
355
|
+
# Generate markdown report
|
|
356
|
+
if new_vulns:
|
|
357
|
+
report.append("\n### New Vulnerabilities\n")
|
|
358
|
+
grouped_new = group_by_severity(new_vulns, current_vulns)
|
|
359
|
+
for severity, vulns in grouped_new.items():
|
|
360
|
+
report.append(f"\n#### {severity}\n")
|
|
361
|
+
for vuln in sorted(vulns, key=lambda x: x["id"]):
|
|
362
|
+
vuln_line = (
|
|
363
|
+
f"- {vuln['id']} in {vuln['pkg']} " f"{vuln['version']} ({vuln['target']})"
|
|
364
|
+
)
|
|
365
|
+
report.append(vuln_line)
|
|
366
|
+
|
|
367
|
+
if fixed_vulns:
|
|
368
|
+
report.append("\n### Fixed Vulnerabilities\n")
|
|
369
|
+
grouped_fixed = group_by_severity(fixed_vulns, target_vulns)
|
|
370
|
+
for severity, vulns in grouped_fixed.items():
|
|
371
|
+
report.append(f"\n#### {severity}\n")
|
|
372
|
+
for vuln in sorted(vulns, key=lambda x: x["id"]):
|
|
373
|
+
vuln_line = (
|
|
374
|
+
f"- {vuln['id']} in {vuln['pkg']} " f"{vuln['version']} ({vuln['target']})"
|
|
375
|
+
)
|
|
376
|
+
report.append(vuln_line)
|
|
377
|
+
|
|
378
|
+
if not new_vulns and not fixed_vulns:
|
|
379
|
+
report.append("\nNo vulnerability changes detected between branches.")
|
|
380
|
+
analysis_data.append("\nNo security changes to analyze.")
|
|
381
|
+
|
|
382
|
+
return "\n".join(report), "\n".join(analysis_data)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class ColorHelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
386
|
+
"""Custom formatter to preserve colors in help text."""
|
|
387
|
+
|
|
388
|
+
def _split_lines(self, text, width):
|
|
389
|
+
return text.splitlines()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_args(args=None):
|
|
393
|
+
"""Parse command line arguments."""
|
|
394
|
+
parser = argparse.ArgumentParser(
|
|
395
|
+
description="Generate PR description from git diff",
|
|
396
|
+
formatter_class=ColorHelpFormatter,
|
|
397
|
+
epilog=f"""
|
|
398
|
+
recommended models:
|
|
399
|
+
{YELLOW}claude-3.5-sonnet{ENDC} (default) Anthropic's Claude 3.5 Sonnet
|
|
400
|
+
{YELLOW}azure/o1-mini{ENDC} Azure OpenAI o1-mini
|
|
401
|
+
{YELLOW}azure/gpt-4o{ENDC} Azure OpenAI GPT-4
|
|
402
|
+
{GREEN}gpt-4{ENDC} OpenAI GPT-4
|
|
403
|
+
|
|
404
|
+
prompt templates:
|
|
405
|
+
{BLUE}meta{ENDC} Default XML prompt template for merge requests""",
|
|
406
|
+
)
|
|
407
|
+
parser.add_argument("-s", "--silent", action="store_true", help="Silent mode")
|
|
408
|
+
parser.add_argument(
|
|
409
|
+
"-d",
|
|
410
|
+
"--debug",
|
|
411
|
+
action="store_true",
|
|
412
|
+
help="Debug mode - show prompts without sending to LLM",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"-v",
|
|
416
|
+
"--verbose",
|
|
417
|
+
action="store_true",
|
|
418
|
+
help="Verbose mode - show detailed API interaction",
|
|
419
|
+
)
|
|
420
|
+
parser.add_argument("-t", "--target", help="Target branch for comparison")
|
|
421
|
+
parser.add_argument("--vulns", action="store_true", help="Include vulnerability scan")
|
|
422
|
+
parser.add_argument("--working-tree", action="store_true", help="Use working tree")
|
|
423
|
+
parser.add_argument(
|
|
424
|
+
"-m",
|
|
425
|
+
"--model",
|
|
426
|
+
help="AI model to use (see recommended models below)",
|
|
427
|
+
)
|
|
428
|
+
parser.add_argument(
|
|
429
|
+
"-p",
|
|
430
|
+
"--prompt",
|
|
431
|
+
help=(
|
|
432
|
+
"Specify either a built-in prompt name (e.g., 'meta') or "
|
|
433
|
+
"a path to a custom XML prompt file (e.g., '~/prompts/custom.xml')"
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
return parser.parse_args(args)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def detect_default_branch(repo: git.Repo) -> str:
|
|
440
|
+
"""Detect the default branch of the repository."""
|
|
441
|
+
for branch in ["main", "master", "develop"]:
|
|
442
|
+
try:
|
|
443
|
+
repo.git.rev_parse("--verify", branch)
|
|
444
|
+
return branch
|
|
445
|
+
except git.exc.GitCommandError:
|
|
446
|
+
continue
|
|
447
|
+
raise Exception("Could not detect default branch")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def get_vulnerability_data() -> Optional[str]:
|
|
451
|
+
"""Get vulnerability scan data using trivy."""
|
|
452
|
+
try:
|
|
453
|
+
result = subprocess.run(
|
|
454
|
+
["trivy", "fs", "--quiet", "--severity", "HIGH,CRITICAL", "."],
|
|
455
|
+
capture_output=True,
|
|
456
|
+
text=True,
|
|
457
|
+
)
|
|
458
|
+
return result.stdout if result.stdout.strip() else None
|
|
459
|
+
except FileNotFoundError:
|
|
460
|
+
print("Warning: trivy not found. Skipping vulnerability scan.")
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def generate_description(
|
|
465
|
+
diff: str,
|
|
466
|
+
vuln_data: Optional[str],
|
|
467
|
+
provider: str,
|
|
468
|
+
model: str,
|
|
469
|
+
system_prompt: str,
|
|
470
|
+
verbose: bool = False,
|
|
471
|
+
prompt_manager: Optional[PromptManager] = None,
|
|
472
|
+
) -> str:
|
|
473
|
+
"""Generate description using the specified provider."""
|
|
474
|
+
# Get the user prompt from the prompt manager
|
|
475
|
+
if prompt_manager is None:
|
|
476
|
+
prompt_manager = PromptManager()
|
|
477
|
+
user_prompt = prompt_manager.get_user_prompt(diff, vuln_data)
|
|
478
|
+
|
|
479
|
+
if provider == "anthropic":
|
|
480
|
+
return generate_with_anthropic(user_prompt, vuln_data, model, system_prompt, verbose)
|
|
481
|
+
if provider == "azure":
|
|
482
|
+
return generate_with_azure_openai(user_prompt, vuln_data, model, system_prompt, verbose)
|
|
483
|
+
if provider == "openai":
|
|
484
|
+
return generate_with_openai(user_prompt, vuln_data, model, system_prompt, verbose)
|
|
485
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def main(args=None):
|
|
489
|
+
"""Main entry point for AIPR"""
|
|
490
|
+
args = parse_args(args)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
repo = git.Repo(os.getcwd(), search_parent_directories=True)
|
|
494
|
+
except git.InvalidGitRepositoryError:
|
|
495
|
+
print(f"{RED}Error: Directory is not a valid Git repository{ENDC}", file=sys.stderr)
|
|
496
|
+
sys.exit(1)
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
prompt_manager = PromptManager(args.prompt)
|
|
500
|
+
except InvalidPromptError as e:
|
|
501
|
+
print(f"{RED}Error: {str(e)}{ENDC}")
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
|
|
504
|
+
provider, model = detect_provider_and_model(args.model)
|
|
505
|
+
|
|
506
|
+
# Get the diff based on the state
|
|
507
|
+
diff = ""
|
|
508
|
+
if args.target == "-" or repo.is_dirty():
|
|
509
|
+
# Show working tree changes
|
|
510
|
+
if not args.silent:
|
|
511
|
+
print(f"{BLUE}Showing working tree changes...{ENDC}", file=sys.stderr)
|
|
512
|
+
diff = repo.git.diff("HEAD", "--cached") + "\n" + repo.git.diff()
|
|
513
|
+
else:
|
|
514
|
+
# Compare with target branch
|
|
515
|
+
target = args.target
|
|
516
|
+
if not target:
|
|
517
|
+
# Try to find default branch
|
|
518
|
+
for branch in ["main", "master", "develop"]:
|
|
519
|
+
if branch in [h.name for h in repo.heads]:
|
|
520
|
+
target = branch
|
|
521
|
+
break
|
|
522
|
+
|
|
523
|
+
if target:
|
|
524
|
+
if not args.silent:
|
|
525
|
+
print(f"{BLUE}Comparing with {target}...{ENDC}", file=sys.stderr)
|
|
526
|
+
diff = repo.git.diff(f"{target}...{repo.active_branch.name}")
|
|
527
|
+
else:
|
|
528
|
+
print(f"{YELLOW}No suitable target branch found.{ENDC}", file=sys.stderr)
|
|
529
|
+
sys.exit(1)
|
|
530
|
+
|
|
531
|
+
if not diff.strip():
|
|
532
|
+
print("No changes found in the Git repository.", file=sys.stderr)
|
|
533
|
+
sys.exit(0)
|
|
534
|
+
|
|
535
|
+
# Get vulnerability data if requested
|
|
536
|
+
vuln_data = None
|
|
537
|
+
if args.vulns:
|
|
538
|
+
if not args.silent:
|
|
539
|
+
print(f"{BLUE}Running vulnerability scan...{ENDC}", file=sys.stderr)
|
|
540
|
+
vuln_data = run_trivy_scan(repo.working_dir, args.silent, False)
|
|
541
|
+
|
|
542
|
+
# Generate the description
|
|
543
|
+
if not args.silent:
|
|
544
|
+
print_header("\nGenerating Description")
|
|
545
|
+
print(f"Using {provider} ({model})...")
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
# In debug mode, show the prompts that would be sent
|
|
549
|
+
if args.debug:
|
|
550
|
+
# Get the prompts first
|
|
551
|
+
system_prompt = prompt_manager.get_system_prompt()
|
|
552
|
+
user_prompt = prompt_manager.get_user_prompt(diff, vuln_data)
|
|
553
|
+
|
|
554
|
+
# Prepare the API parameters
|
|
555
|
+
if provider == "azure" and model == "o1-mini":
|
|
556
|
+
combined_prompt = (
|
|
557
|
+
f"System Instructions:\n{system_prompt}\n\n" f"User Request:\n{user_prompt}"
|
|
558
|
+
)
|
|
559
|
+
messages = [{"role": "user", "content": combined_prompt}]
|
|
560
|
+
params = {
|
|
561
|
+
"model": model,
|
|
562
|
+
"messages": messages,
|
|
563
|
+
"max_completion_tokens": 1000,
|
|
564
|
+
}
|
|
565
|
+
elif provider == "anthropic":
|
|
566
|
+
params = {
|
|
567
|
+
"model": model,
|
|
568
|
+
"system": system_prompt,
|
|
569
|
+
"messages": [{"role": "user", "content": user_prompt}],
|
|
570
|
+
"max_tokens": 1000,
|
|
571
|
+
"temperature": 0.2,
|
|
572
|
+
}
|
|
573
|
+
else:
|
|
574
|
+
params = {
|
|
575
|
+
"model": model,
|
|
576
|
+
"messages": [
|
|
577
|
+
{"role": "system", "content": system_prompt},
|
|
578
|
+
{"role": "user", "content": user_prompt},
|
|
579
|
+
],
|
|
580
|
+
"max_tokens": 1000,
|
|
581
|
+
"temperature": 0.2,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
# Print debug information in a structured way
|
|
585
|
+
print_header("Debug Information")
|
|
586
|
+
|
|
587
|
+
print_header("API Call Structure", level=2)
|
|
588
|
+
print(f"Provider: {provider}")
|
|
589
|
+
print(f"Model: {model}")
|
|
590
|
+
print(
|
|
591
|
+
f"Endpoint: {os.getenv('AZURE_OPENAI_ENDPOINT', 'Not Set')}"
|
|
592
|
+
if provider == "azure"
|
|
593
|
+
else ""
|
|
594
|
+
)
|
|
595
|
+
print("\nParameters:")
|
|
596
|
+
print("─" * 40)
|
|
597
|
+
print(json.dumps({k: v for k, v in params.items() if k != "messages"}, indent=2))
|
|
598
|
+
print("\nMessages:")
|
|
599
|
+
print("─" * 40)
|
|
600
|
+
for msg in params["messages"]:
|
|
601
|
+
print(f"\n{msg['role'].upper()} MESSAGE:")
|
|
602
|
+
print(msg["content"])
|
|
603
|
+
print()
|
|
604
|
+
sys.exit(0)
|
|
605
|
+
|
|
606
|
+
# Generate the description
|
|
607
|
+
description = generate_description(
|
|
608
|
+
diff,
|
|
609
|
+
vuln_data,
|
|
610
|
+
provider,
|
|
611
|
+
model,
|
|
612
|
+
(
|
|
613
|
+
prompt_manager.get_system_prompt()
|
|
614
|
+
if prompt_manager
|
|
615
|
+
else PromptManager().get_system_prompt()
|
|
616
|
+
),
|
|
617
|
+
args.verbose,
|
|
618
|
+
prompt_manager or PromptManager(), # Ensure we always pass a valid PromptManager
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
if args.verbose:
|
|
622
|
+
print("\nAPI Response:")
|
|
623
|
+
print("─" * 40)
|
|
624
|
+
print(description)
|
|
625
|
+
|
|
626
|
+
except Exception as e:
|
|
627
|
+
print(f"{RED}Error: {provider.title()} API - {e}{ENDC}", file=sys.stderr)
|
|
628
|
+
sys.exit(1)
|
|
629
|
+
|
|
630
|
+
sys.exit(0)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
main()
|
aipr/prompts/__init__.py
ADDED
aipr/prompts/meta.xml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<prompt>
|
|
3
|
+
<purpose>
|
|
4
|
+
Given a change-set and optional vulnerability-set generate a merge request description.
|
|
5
|
+
Strictly follow the instructions.
|
|
6
|
+
Format output like the example provided.
|
|
7
|
+
Always include a Security Analysis section.
|
|
8
|
+
</purpose>
|
|
9
|
+
|
|
10
|
+
<instructions>
|
|
11
|
+
<always>Focus only on the specific changes shown in the diff and vulnerability comparison.</always>
|
|
12
|
+
<always>Directly tie each point to actual code changes or security findings.</always>
|
|
13
|
+
<always>Highlight critical security changes when analyzing vulnerabilities.</always>
|
|
14
|
+
<always>Explain the impact of new vulnerabilities when provided.</always>
|
|
15
|
+
<always>Acknowledge any fixed vulnerabilities.</always>
|
|
16
|
+
<always>If tempted to write a concluding statement after the Security Analysis, do not continue.</always>
|
|
17
|
+
<never>Do not include generic concluding statements (e.g., "This improves the overall system").</never>
|
|
18
|
+
<never>Do not include broad claims about improvements (e.g., "This enhances development processes").</never>
|
|
19
|
+
<never>Do not include value judgments about the changes (e.g., "This is a significant improvement").</never>
|
|
20
|
+
<never>Do not include future benefits or implications.</never>
|
|
21
|
+
</instructions>
|
|
22
|
+
|
|
23
|
+
<example>
|
|
24
|
+
Change Summary:
|
|
25
|
+
|
|
26
|
+
1. **Added User Authentication**
|
|
27
|
+
- Implemented JWT middleware
|
|
28
|
+
- Added login/register endpoints
|
|
29
|
+
- Updated bcrypt to v5.1.1
|
|
30
|
+
|
|
31
|
+
2. **Security Updates**
|
|
32
|
+
- Fixed 2 medium severity vulnerabilities
|
|
33
|
+
- Updated deprecated crypto functions
|
|
34
|
+
|
|
35
|
+
Security Analysis:
|
|
36
|
+
✓ No new vulnerabilities introduced
|
|
37
|
+
- Previous medium severity vulnerabilities in bcrypt have been resolved
|
|
38
|
+
- All crypto functions now use current best practices
|
|
39
|
+
</example>
|
|
40
|
+
|
|
41
|
+
<changes-set>
|
|
42
|
+
{{change_set}}
|
|
43
|
+
</changes-set>
|
|
44
|
+
|
|
45
|
+
<vulnerabilities-set>
|
|
46
|
+
{{vulnerability_set}}
|
|
47
|
+
</vulnerabilities-set>
|
|
48
|
+
|
|
49
|
+
</prompt>
|
aipr/prompts/prompts.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Prompt management for AIPR."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidPromptError(Exception):
|
|
12
|
+
"""Raised when a prompt file is invalid."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PromptManager:
|
|
18
|
+
"""Manages system and user prompts for AI models."""
|
|
19
|
+
|
|
20
|
+
REQUIRED_XML_ELEMENTS = ["changes-set", "vulnerabilities-set"]
|
|
21
|
+
|
|
22
|
+
def __init__(self, prompt_name: str = None):
|
|
23
|
+
self._default_system_prompt = (
|
|
24
|
+
"You are a helpful assistant for generating Merge Requests.\n"
|
|
25
|
+
"Your task is to analyze Git changes and vulnerability comparison data to create "
|
|
26
|
+
"clear, well-structured merge request descriptions.\n"
|
|
27
|
+
"Response should end with the last specific change or security finding discussed.\n"
|
|
28
|
+
"If you find yourself wanting to write a concluding statement, stop writing instead."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.prompt_name = prompt_name
|
|
32
|
+
self._xml_prompt = None
|
|
33
|
+
|
|
34
|
+
if prompt_name:
|
|
35
|
+
self._load_prompt(prompt_name)
|
|
36
|
+
|
|
37
|
+
def _load_prompt(self, prompt_name: str) -> None:
|
|
38
|
+
"""Load a prompt from either a file path or a built-in prompt name."""
|
|
39
|
+
# First check if this is a file path (has .xml extension)
|
|
40
|
+
path = Path(os.path.expanduser(prompt_name))
|
|
41
|
+
if path.suffix == ".xml":
|
|
42
|
+
if path.exists():
|
|
43
|
+
self._load_xml_prompt(str(path))
|
|
44
|
+
return
|
|
45
|
+
# Try local prompts directory
|
|
46
|
+
local_path = Path("prompts") / path.name
|
|
47
|
+
if local_path.exists():
|
|
48
|
+
self._load_xml_prompt(str(local_path))
|
|
49
|
+
return
|
|
50
|
+
raise InvalidPromptError(f"Prompt file not found: {path}")
|
|
51
|
+
|
|
52
|
+
# If no .xml extension, treat as a built-in prompt name
|
|
53
|
+
available_prompts = self._get_available_prompts()
|
|
54
|
+
try:
|
|
55
|
+
with (
|
|
56
|
+
importlib.resources.files("aipr.prompts").joinpath(f"{prompt_name}.xml").open("r")
|
|
57
|
+
) as f:
|
|
58
|
+
self._load_xml_prompt_from_string(f.read())
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise InvalidPromptError(
|
|
61
|
+
f"Could not load prompt '{prompt_name}'. "
|
|
62
|
+
f"Error: {e}\n\n"
|
|
63
|
+
f"Available built-in prompts: {', '.join(available_prompts)}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _validate_xml_prompt(self, root: ET.Element) -> None:
|
|
67
|
+
"""Validate that the XML prompt has all required elements."""
|
|
68
|
+
for element in self.REQUIRED_XML_ELEMENTS:
|
|
69
|
+
if root.find(f".//{element}") is None:
|
|
70
|
+
raise InvalidPromptError(
|
|
71
|
+
f"Invalid prompt file: Missing required element '{element}'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _load_xml_prompt(self, file_path: str) -> None:
|
|
75
|
+
"""Load and parse the XML prompt template from a file."""
|
|
76
|
+
try:
|
|
77
|
+
tree = ET.parse(file_path)
|
|
78
|
+
root = tree.getroot()
|
|
79
|
+
self._validate_xml_prompt(root)
|
|
80
|
+
self._xml_prompt = root
|
|
81
|
+
except ET.ParseError as e:
|
|
82
|
+
raise InvalidPromptError(f"Error parsing XML prompt file: {e}")
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
raise InvalidPromptError(f"Prompt file not found: {file_path}")
|
|
85
|
+
except PermissionError:
|
|
86
|
+
raise InvalidPromptError(f"Permission denied reading prompt file: {file_path}")
|
|
87
|
+
|
|
88
|
+
def _load_xml_prompt_from_string(self, xml_content: str) -> None:
|
|
89
|
+
"""Load and parse the XML prompt template from a string."""
|
|
90
|
+
try:
|
|
91
|
+
root = ET.fromstring(xml_content)
|
|
92
|
+
self._validate_xml_prompt(root)
|
|
93
|
+
self._xml_prompt = root
|
|
94
|
+
except ET.ParseError as e:
|
|
95
|
+
raise InvalidPromptError(f"Error parsing XML prompt content: {e}")
|
|
96
|
+
|
|
97
|
+
def _get_available_prompts(self) -> list[str]:
|
|
98
|
+
"""Get list of available built-in prompts."""
|
|
99
|
+
try:
|
|
100
|
+
prompts_dir = importlib.resources.files("aipr.prompts")
|
|
101
|
+
return [
|
|
102
|
+
f.stem for f in prompts_dir.iterdir() if f.suffix == ".xml" and f.stem != "__init__"
|
|
103
|
+
]
|
|
104
|
+
except Exception:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
def get_system_prompt(self) -> str:
|
|
108
|
+
"""Get the system prompt."""
|
|
109
|
+
return self._default_system_prompt
|
|
110
|
+
|
|
111
|
+
def get_user_prompt(self, diff: str, vuln_data: Union[str, dict, None] = None) -> str:
|
|
112
|
+
"""Get the user prompt with diff and optional vulnerability data."""
|
|
113
|
+
if self.prompt_name:
|
|
114
|
+
if self._xml_prompt is None:
|
|
115
|
+
raise ValueError("XML prompt file was specified but could not be loaded")
|
|
116
|
+
|
|
117
|
+
# Find the changes-set and vulnerabilities-set elements
|
|
118
|
+
changes_set = self._xml_prompt.find(".//changes-set")
|
|
119
|
+
vulns_set = self._xml_prompt.find(".//vulnerabilities-set")
|
|
120
|
+
|
|
121
|
+
# Update the content
|
|
122
|
+
if changes_set is not None:
|
|
123
|
+
changes_set.text = "\n" + diff + "\n" # Add newlines for better formatting
|
|
124
|
+
if vulns_set is not None:
|
|
125
|
+
if vuln_data:
|
|
126
|
+
vulns_set.text = (
|
|
127
|
+
"\n"
|
|
128
|
+
+ (
|
|
129
|
+
json.dumps(vuln_data, indent=2)
|
|
130
|
+
if isinstance(vuln_data, dict)
|
|
131
|
+
else str(vuln_data)
|
|
132
|
+
)
|
|
133
|
+
+ "\n"
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
vulns_set.text = ""
|
|
137
|
+
|
|
138
|
+
# Get the example section
|
|
139
|
+
example = self._xml_prompt.find(".//example")
|
|
140
|
+
if example is not None:
|
|
141
|
+
example.tail = "\n" # Add newline after example
|
|
142
|
+
|
|
143
|
+
# Convert to string while preserving formatting and remove XML declaration
|
|
144
|
+
xml_str = ET.tostring(self._xml_prompt, encoding="unicode", method="xml")
|
|
145
|
+
if xml_str.startswith("<?xml"):
|
|
146
|
+
xml_str = xml_str[xml_str.find("?>") + 2 :]
|
|
147
|
+
|
|
148
|
+
# Return the XML structure directly without wrapping it in additional text
|
|
149
|
+
return xml_str.strip()
|
|
150
|
+
|
|
151
|
+
# Default format when no XML prompt file is specified
|
|
152
|
+
prompt = [
|
|
153
|
+
"Please include:",
|
|
154
|
+
"- A concise summary of the changes",
|
|
155
|
+
"- Key modifications and their purpose",
|
|
156
|
+
"- Any notable technical details",
|
|
157
|
+
"- Security impact analysis (when vulnerability data is provided)",
|
|
158
|
+
"",
|
|
159
|
+
"Important Guidelines:",
|
|
160
|
+
"1. Focus only on the specific changes shown in the diff and vulnerability comparison",
|
|
161
|
+
"2. Each point must be directly tied to actual code changes or security findings",
|
|
162
|
+
"3. When analyzing vulnerabilities:",
|
|
163
|
+
" - Highlight critical security changes",
|
|
164
|
+
" - Explain the impact of new vulnerabilities",
|
|
165
|
+
" - Acknowledge fixed vulnerabilities",
|
|
166
|
+
"4. DO NOT include any of the following:",
|
|
167
|
+
' - Generic concluding statements (e.g., "This improves the overall system")',
|
|
168
|
+
' - Broad claims about improvements (e.g., "This enhances development processes")',
|
|
169
|
+
' - Value judgments about the changes (e.g., "This is a significant improvement")',
|
|
170
|
+
" - Future benefits or implications",
|
|
171
|
+
"",
|
|
172
|
+
"Git Diff:",
|
|
173
|
+
diff,
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
if vuln_data:
|
|
177
|
+
prompt.extend(
|
|
178
|
+
[
|
|
179
|
+
"",
|
|
180
|
+
"Vulnerability Analysis:",
|
|
181
|
+
(
|
|
182
|
+
json.dumps(vuln_data, indent=2)
|
|
183
|
+
if isinstance(vuln_data, dict)
|
|
184
|
+
else str(vuln_data)
|
|
185
|
+
),
|
|
186
|
+
]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return "\n".join(prompt)
|
aipr/providers.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import anthropic
|
|
6
|
+
from openai import AzureOpenAI, OpenAI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_with_anthropic(
|
|
10
|
+
diff: str,
|
|
11
|
+
vuln_data: Optional[Dict[str, Any]],
|
|
12
|
+
model: str,
|
|
13
|
+
system_prompt: str,
|
|
14
|
+
verbose: bool = False,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Generate description using Anthropic's Claude."""
|
|
17
|
+
if verbose:
|
|
18
|
+
print("\nInitializing Anthropic client...")
|
|
19
|
+
|
|
20
|
+
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
|
|
21
|
+
|
|
22
|
+
if verbose:
|
|
23
|
+
print("\nSending request to Anthropic API:")
|
|
24
|
+
print(f" Model: {model}")
|
|
25
|
+
print(" Parameters:", json.dumps({"max_tokens": 1000, "temperature": 0.2}, indent=2))
|
|
26
|
+
print("\nRequest Messages:")
|
|
27
|
+
print("\nSYSTEM MESSAGE:")
|
|
28
|
+
print(system_prompt)
|
|
29
|
+
print("\nUSER MESSAGE:")
|
|
30
|
+
if len(diff) > 500:
|
|
31
|
+
print(diff[:500] + "...")
|
|
32
|
+
else:
|
|
33
|
+
print(diff)
|
|
34
|
+
print("\nMaking API call...")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
response = client.messages.create(
|
|
38
|
+
model=model,
|
|
39
|
+
max_tokens=1000,
|
|
40
|
+
temperature=0.2,
|
|
41
|
+
system=system_prompt,
|
|
42
|
+
messages=[{"role": "user", "content": diff}],
|
|
43
|
+
)
|
|
44
|
+
if verbose:
|
|
45
|
+
print("\nRaw API Response:")
|
|
46
|
+
print(f" Model: {response.model}")
|
|
47
|
+
print(f" Usage: {response.usage.model_dump() if response.usage else 'N/A'}")
|
|
48
|
+
print("\nResponse Content:")
|
|
49
|
+
return response.content[0].text
|
|
50
|
+
except Exception as e:
|
|
51
|
+
if verbose:
|
|
52
|
+
print(f"\nAPI Error: {str(e)}")
|
|
53
|
+
raise ValueError(f"Anthropic API error: {str(e)}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_with_azure_openai(
|
|
57
|
+
diff: str,
|
|
58
|
+
vuln_data: Optional[Dict[str, Any]],
|
|
59
|
+
model: str,
|
|
60
|
+
system_prompt: str,
|
|
61
|
+
verbose: bool = False,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Generate description using Azure OpenAI."""
|
|
64
|
+
# Check required environment variables
|
|
65
|
+
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
|
66
|
+
api_key = os.getenv("AZURE_API_KEY")
|
|
67
|
+
|
|
68
|
+
if not endpoint or not api_key:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Missing Azure OpenAI configuration. "
|
|
71
|
+
"Please set AZURE_OPENAI_ENDPOINT and AZURE_API_KEY environment variables."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if verbose:
|
|
76
|
+
print("\nInitializing Azure OpenAI client with:")
|
|
77
|
+
print(f" Endpoint: {endpoint}")
|
|
78
|
+
print(" API Version: 2024-02-15-preview")
|
|
79
|
+
|
|
80
|
+
client = AzureOpenAI(
|
|
81
|
+
api_key=api_key,
|
|
82
|
+
api_version="2024-02-15-preview",
|
|
83
|
+
azure_endpoint=endpoint,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# For models that don't support system messages (like o1-mini), prepend it to user message
|
|
87
|
+
messages = []
|
|
88
|
+
if model in ["o1-mini"]:
|
|
89
|
+
combined_prompt = f"System Instructions:\n{system_prompt}\n\n{diff}"
|
|
90
|
+
messages = [{"role": "user", "content": combined_prompt}]
|
|
91
|
+
else:
|
|
92
|
+
messages = [
|
|
93
|
+
{"role": "system", "content": system_prompt},
|
|
94
|
+
{"role": "user", "content": diff},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# o1-mini has special parameter requirements:
|
|
98
|
+
# - doesn't support temperature parameter
|
|
99
|
+
kwargs = {
|
|
100
|
+
"model": model,
|
|
101
|
+
"messages": messages,
|
|
102
|
+
}
|
|
103
|
+
if model not in ["o1-mini"]: # Only set these for non-o1-mini models
|
|
104
|
+
kwargs["max_tokens"] = 1000
|
|
105
|
+
kwargs["temperature"] = 0.2
|
|
106
|
+
|
|
107
|
+
if verbose:
|
|
108
|
+
print("\nSending request to Azure OpenAI API:")
|
|
109
|
+
print(f" Model: {model}")
|
|
110
|
+
print(
|
|
111
|
+
" Parameters:",
|
|
112
|
+
json.dumps({k: v for k, v in kwargs.items() if k != "messages"}, indent=2),
|
|
113
|
+
)
|
|
114
|
+
print("\nRequest Messages:")
|
|
115
|
+
for msg in messages:
|
|
116
|
+
print(f"\n{msg['role'].upper()} MESSAGE:")
|
|
117
|
+
content = msg["content"]
|
|
118
|
+
if len(content) > 500:
|
|
119
|
+
print(content[:500] + "...")
|
|
120
|
+
else:
|
|
121
|
+
print(content)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
if verbose:
|
|
125
|
+
print("\nMaking API call...")
|
|
126
|
+
response = client.chat.completions.create(**kwargs)
|
|
127
|
+
if not response.choices:
|
|
128
|
+
raise ValueError("No completion choices returned from the API")
|
|
129
|
+
if verbose:
|
|
130
|
+
print("\nRaw API Response:")
|
|
131
|
+
print(f" Model: {response.model}")
|
|
132
|
+
print(f" Usage: {response.usage.model_dump() if response.usage else 'N/A'}")
|
|
133
|
+
print("\nResponse Content:")
|
|
134
|
+
return response.choices[0].message.content
|
|
135
|
+
except Exception as api_error:
|
|
136
|
+
error_msg = str(api_error)
|
|
137
|
+
if verbose:
|
|
138
|
+
print(f"\nAPI Error: {error_msg}")
|
|
139
|
+
if "status" in error_msg:
|
|
140
|
+
raise ValueError(f"Azure OpenAI API error (HTTP {error_msg})")
|
|
141
|
+
raise ValueError(f"Azure OpenAI API error: {error_msg}")
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
if verbose:
|
|
145
|
+
print(f"\nProvider Error: {str(e)}")
|
|
146
|
+
raise ValueError(f"Azure OpenAI provider error: {str(e)}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def generate_with_openai(
|
|
150
|
+
diff: str,
|
|
151
|
+
vuln_data: Optional[Dict[str, Any]],
|
|
152
|
+
model: str,
|
|
153
|
+
system_prompt: str,
|
|
154
|
+
verbose: bool = False,
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Generate description using OpenAI."""
|
|
157
|
+
if verbose:
|
|
158
|
+
print("\nInitializing OpenAI client...")
|
|
159
|
+
|
|
160
|
+
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
161
|
+
|
|
162
|
+
messages = [
|
|
163
|
+
{"role": "system", "content": system_prompt},
|
|
164
|
+
{"role": "user", "content": diff},
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
if verbose:
|
|
168
|
+
print("\nSending request to OpenAI API:")
|
|
169
|
+
print(f" Model: {model}")
|
|
170
|
+
print(" Parameters:", json.dumps({"max_tokens": 1000, "temperature": 0.2}, indent=2))
|
|
171
|
+
print("\nRequest Messages:")
|
|
172
|
+
for msg in messages:
|
|
173
|
+
print(f"\n{msg['role'].upper()} MESSAGE:")
|
|
174
|
+
content = msg["content"]
|
|
175
|
+
if len(content) > 500:
|
|
176
|
+
print(content[:500] + "...")
|
|
177
|
+
else:
|
|
178
|
+
print(content)
|
|
179
|
+
print("\nMaking API call...")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
response = client.chat.completions.create(
|
|
183
|
+
model=model,
|
|
184
|
+
messages=messages,
|
|
185
|
+
max_tokens=1000,
|
|
186
|
+
temperature=0.2,
|
|
187
|
+
)
|
|
188
|
+
if verbose:
|
|
189
|
+
print("\nRaw API Response:")
|
|
190
|
+
print(f" Model: {response.model}")
|
|
191
|
+
print(f" Usage: {response.usage.model_dump() if response.usage else 'N/A'}")
|
|
192
|
+
print("\nResponse Content:")
|
|
193
|
+
return response.choices[0].message.content
|
|
194
|
+
except Exception as e:
|
|
195
|
+
if verbose:
|
|
196
|
+
print(f"\nAPI Error: {str(e)}")
|
|
197
|
+
raise ValueError(f"OpenAI API error: {str(e)}")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pr-generator-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AI-powered Pull Request Description Generator
|
|
5
|
+
Author-email: Daniel Scholl <daniel.scholl@microsoft.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: anthropic>=0.18.1
|
|
10
|
+
Requires-Dist: gitpython>=3.1.42
|
|
11
|
+
Requires-Dist: openai>=1.12.0
|
|
12
|
+
Requires-Dist: tiktoken>=0.6.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: black>=24.2.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: build>=1.0.3; extra == 'dev'
|
|
16
|
+
Requires-Dist: flake8>=7.0.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: isort>=5.13.2; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0.2; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# AIPR - Agentic Pull Request Description Generator
|
|
23
|
+
|
|
24
|
+
Automatically analyze git diffs and vulnerabilities to generate comprehensive, well-structured pull request descriptions. By intelligently detecting changes, performing security scans, and leveraging state-of-the-art AI models, AIPR helps teams save time while maintaining high-quality, consistent pull request descriptions.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Install with pipx (recommended)
|
|
28
|
+
pipx install pr-generator-agent
|
|
29
|
+
|
|
30
|
+
# Or with pip
|
|
31
|
+
pip install pr-generator-agent
|
|
32
|
+
|
|
33
|
+
# Set the environment variable for the API key
|
|
34
|
+
export ANTHROPIC_API_KEY="your-api-key"
|
|
35
|
+
|
|
36
|
+
# Generate a PR description
|
|
37
|
+
aipr
|
|
38
|
+
|
|
39
|
+
# Custom usage - Analyze changes against main branch
|
|
40
|
+
# Include: Vulnerability scanning
|
|
41
|
+
# Use: Azure OpenAI o1-mini model
|
|
42
|
+
# Prompt: meta template
|
|
43
|
+
# Ouptut: Verbose
|
|
44
|
+
aipr -t main --vulns -p meta -m azure/o1-mini -v
|
|
45
|
+
|
|
46
|
+
# Inline with merge request creation
|
|
47
|
+
gh pr create -b "$(aipr -s)" -t "feat: New Feature"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Key Features
|
|
51
|
+
|
|
52
|
+
- 🔍 **Smart Detection**: Automatically analyzes working tree changes or compares branches
|
|
53
|
+
- 🛡️ **Security First**: Optional vulnerability scanning between branches using Trivy
|
|
54
|
+
- 🤖 **AI-Powered**: Multiple AI providers (Azure OpenAI, OpenAI, Anthropic) for optimal results
|
|
55
|
+
- 🔄 **CI/CD Ready**: Seamless integration with GitLab and GitHub workflows
|
|
56
|
+
|
|
57
|
+
## Example Output
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Change Summary:
|
|
61
|
+
|
|
62
|
+
1. **Added User Authentication**
|
|
63
|
+
- Implemented JWT middleware
|
|
64
|
+
- Added login/register endpoints
|
|
65
|
+
- Updated bcrypt to v5.1.1
|
|
66
|
+
|
|
67
|
+
2. **Security Updates**
|
|
68
|
+
- Fixed 2 medium severity vulnerabilities
|
|
69
|
+
- Updated deprecated crypto functions
|
|
70
|
+
|
|
71
|
+
Security Analysis:
|
|
72
|
+
✓ No new vulnerabilities introduced
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
- Python 3.10 or higher (3.10, 3.11 officially supported)
|
|
78
|
+
- Git
|
|
79
|
+
- LLM API Key (Anthropic, OpenAI, or Azure OpenAI)
|
|
80
|
+
- [Trivy](https://aquasecurity.github.io/trivy/latest/getting-started/installation/) (used for `--vulns` scanning)
|
|
81
|
+
|
|
82
|
+
## Environment Variables
|
|
83
|
+
|
|
84
|
+
#### Anthropic (Default)
|
|
85
|
+
- `ANTHROPIC_API_KEY`: Anthropic API key
|
|
86
|
+
|
|
87
|
+
#### Azure OpenAI
|
|
88
|
+
- `AZURE_API_KEY`: Azure OpenAI API key
|
|
89
|
+
- `AZURE_API_BASE`: Azure endpoint URL
|
|
90
|
+
- `AZURE_API_VERSION`: API version (default: "2024-02-15-preview")
|
|
91
|
+
|
|
92
|
+
#### OpenAI
|
|
93
|
+
- `OPENAI_API_KEY`: OpenAI API key
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
### Command Options
|
|
98
|
+
- `-t, --target`: Compare changes with specific branch (default: auto-detects main/master)
|
|
99
|
+
- `-p, --prompt`: Select prompt template (e.g., 'meta')
|
|
100
|
+
- `-v, --verbose`: Show API interaction details
|
|
101
|
+
- `-d, --debug`: Preview prompts without API calls
|
|
102
|
+
- `-s, --silent`: Output only the description
|
|
103
|
+
- `--vulns`: Include vulnerability scanning
|
|
104
|
+
- `-m, --model`: Specify AI model to use
|
|
105
|
+
|
|
106
|
+
The tool intelligently detects changes by:
|
|
107
|
+
1. Using staged/unstaged changes if present
|
|
108
|
+
2. Comparing against target branch if working tree is clean
|
|
109
|
+
|
|
110
|
+
## Supported AI Models
|
|
111
|
+
|
|
112
|
+
Choose from multiple AI providers:
|
|
113
|
+
|
|
114
|
+
| Provider | Model | Notes |
|
|
115
|
+
|----------|--------|-------|
|
|
116
|
+
| Anthropic | `claude-3-sonnet` | default |
|
|
117
|
+
| | `claude-3.5-sonnet` | latest |
|
|
118
|
+
| | `claude-3.5-haiku` | latest |
|
|
119
|
+
| | `claude-3-opus` | latest |
|
|
120
|
+
| | `claude-3-haiku` | |
|
|
121
|
+
| | `claude` | alias for `claude-3-sonnet` |
|
|
122
|
+
| Azure OpenAI | `azure/o1-mini` | |
|
|
123
|
+
| | `azure/gpt-4o-mini` | |
|
|
124
|
+
| | `azure/gpt-4o` | |
|
|
125
|
+
| | `azure` | alias for `azure/gpt-4o-mini` |
|
|
126
|
+
| OpenAI | `gpt-4o` | |
|
|
127
|
+
| | `gpt-4-turbo` | |
|
|
128
|
+
| | `gpt-3.5-turbo` | |
|
|
129
|
+
| | `openai` | alias for `gpt-4o` |
|
|
130
|
+
|
|
131
|
+
## Custom Prompts
|
|
132
|
+
|
|
133
|
+
AIPR supports custom prompt templates that allow you to tailor merge request descriptions to your team's specific needs. Custom prompts enable you to:
|
|
134
|
+
- Define consistent formatting across your team
|
|
135
|
+
- Include organization-specific requirements
|
|
136
|
+
- Add custom sections and validation rules
|
|
137
|
+
- Provide examples that match your team's standards
|
|
138
|
+
|
|
139
|
+
For detailed information on creating and using custom prompts, see our [Custom Prompts Tutorial](docs/custom_prompts.md).
|
|
140
|
+
|
|
141
|
+
## Contributing
|
|
142
|
+
|
|
143
|
+
We welcome contributions! See our [Contributing Guide](CONTRIBUTING.md) for details on:
|
|
144
|
+
- Setting up your development environment
|
|
145
|
+
- Our development workflow
|
|
146
|
+
- Code style guidelines
|
|
147
|
+
- Pull request process
|
|
148
|
+
- Running tests
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aipr/__init__.py,sha256=PW5IiHykwROknlAhWnyBPW5oPI01M3SwgKXrRX_oAv0,386
|
|
2
|
+
aipr/main.py,sha256=VcYm3zXEAe_kT2DvReWmjtwaNMcQdF2Um24qXm6GEUo,24102
|
|
3
|
+
aipr/providers.py,sha256=gyrGVFQYUhfoq3aTBnZEkcP9LY9e6mITZ39qt278yDk,6635
|
|
4
|
+
aipr/prompts/__init__.py,sha256=VR8pQpDV0l-929daoPTAWa5zTdXfZ-_dmV2rZXhOu3s,140
|
|
5
|
+
aipr/prompts/meta.xml,sha256=VLnvS85KEUG4Td0iqu4Bxs_qachMlHD1qqwFFuNx45M,2053
|
|
6
|
+
aipr/prompts/prompts.py,sha256=SZYlSGDbJ__-_EJQTBLWJCHdDSj0ekws1ujR9trygXE,7693
|
|
7
|
+
pr_generator_agent-1.0.0.dist-info/METADATA,sha256=tWo4MJYyPIAIr83nBQW_TDGPhg_53dc2tyOF1b5wKeE,4857
|
|
8
|
+
pr_generator_agent-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
pr_generator_agent-1.0.0.dist-info/entry_points.txt,sha256=Nx7jYZTtBlWrDKhHkPYD-UBs4xA-rr4Q-KTDzcpeIFU,40
|
|
10
|
+
pr_generator_agent-1.0.0.dist-info/licenses/LICENSE,sha256=IeC3i1PYZv6yj4Pj6rp2Nzs2-F70buj7UTFsmLUJvO8,1070
|
|
11
|
+
pr_generator_agent-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Daniel Scholl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|