code-review-ai-cli 1.3.2__tar.gz → 1.3.4__tar.gz
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.
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/PKG-INFO +12 -4
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/README.md +12 -4
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/PKG-INFO +12 -4
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/pyproject.toml +1 -1
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/ai_review.py +14 -11
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/config.py +7 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/llm_client.py +181 -136
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/prompts/config.yaml.template +5 -1
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/tfs_client.py +19 -14
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_ai_review.py +7 -9
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_llm_client.py +58 -21
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_tfs_client.py +29 -4
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/SOURCES.txt +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/dependency_links.txt +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/entry_points.txt +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/requires.txt +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/top_level.txt +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/setup.cfg +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/__init__.py +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/formatter.py +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/git_utils.py +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/prompts/review_prompt.md.template +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_config.py +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_formatter.py +0 -0
- {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_git_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-review-ai-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: Automated AI-powered code review CLI for Azure DevOps / TFS Pull Requests
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: code-review,ai,azure-devops,tfs,pull-request,llm
|
|
@@ -351,9 +351,8 @@ flowchart TD
|
|
|
351
351
|
R --> S[Limit files with max_diff_files]
|
|
352
352
|
S --> T[Build changed-files summary]
|
|
353
353
|
T --> U[Truncate each file with max_diff_lines]
|
|
354
|
-
U --> V[Run AI
|
|
355
|
-
V -->
|
|
356
|
-
W --> X[Preview review and suggested comments]
|
|
354
|
+
U --> V[Run single AI call: narrative review + structured comments]
|
|
355
|
+
V --> X[Preview review and suggested comments]
|
|
357
356
|
X --> Y{Dry-run enabled?}
|
|
358
357
|
Y -->|Yes| Z[Stop after preview]
|
|
359
358
|
Y -->|No| AA{Auto-post enabled?}
|
|
@@ -392,6 +391,7 @@ Options:
|
|
|
392
391
|
- `--format {terminal,markdown,json}`
|
|
393
392
|
- `--output`, `-o`
|
|
394
393
|
- `--no-color`
|
|
394
|
+
- `--debug-dump`
|
|
395
395
|
- `--model`, `-m`
|
|
396
396
|
- `--provider`, `-p`
|
|
397
397
|
- `--config`
|
|
@@ -428,3 +428,11 @@ tfs:
|
|
|
428
428
|
```
|
|
429
429
|
|
|
430
430
|
Avoid `verify_ssl: false` except for temporary troubleshooting.
|
|
431
|
+
|
|
432
|
+
### Debug Dump
|
|
433
|
+
|
|
434
|
+
```yaml
|
|
435
|
+
debug:
|
|
436
|
+
dump: true
|
|
437
|
+
dump_file: logs/llm_prompt_debug.log
|
|
438
|
+
```
|
|
@@ -314,9 +314,8 @@ flowchart TD
|
|
|
314
314
|
R --> S[Limit files with max_diff_files]
|
|
315
315
|
S --> T[Build changed-files summary]
|
|
316
316
|
T --> U[Truncate each file with max_diff_lines]
|
|
317
|
-
U --> V[Run AI
|
|
318
|
-
V -->
|
|
319
|
-
W --> X[Preview review and suggested comments]
|
|
317
|
+
U --> V[Run single AI call: narrative review + structured comments]
|
|
318
|
+
V --> X[Preview review and suggested comments]
|
|
320
319
|
X --> Y{Dry-run enabled?}
|
|
321
320
|
Y -->|Yes| Z[Stop after preview]
|
|
322
321
|
Y -->|No| AA{Auto-post enabled?}
|
|
@@ -355,6 +354,7 @@ Options:
|
|
|
355
354
|
- `--format {terminal,markdown,json}`
|
|
356
355
|
- `--output`, `-o`
|
|
357
356
|
- `--no-color`
|
|
357
|
+
- `--debug-dump`
|
|
358
358
|
- `--model`, `-m`
|
|
359
359
|
- `--provider`, `-p`
|
|
360
360
|
- `--config`
|
|
@@ -390,4 +390,12 @@ tfs:
|
|
|
390
390
|
ca_bundle: C:/certs/corporate-root-ca.pem
|
|
391
391
|
```
|
|
392
392
|
|
|
393
|
-
Avoid `verify_ssl: false` except for temporary troubleshooting.
|
|
393
|
+
Avoid `verify_ssl: false` except for temporary troubleshooting.
|
|
394
|
+
|
|
395
|
+
### Debug Dump
|
|
396
|
+
|
|
397
|
+
```yaml
|
|
398
|
+
debug:
|
|
399
|
+
dump: true
|
|
400
|
+
dump_file: logs/llm_prompt_debug.log
|
|
401
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-review-ai-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: Automated AI-powered code review CLI for Azure DevOps / TFS Pull Requests
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: code-review,ai,azure-devops,tfs,pull-request,llm
|
|
@@ -351,9 +351,8 @@ flowchart TD
|
|
|
351
351
|
R --> S[Limit files with max_diff_files]
|
|
352
352
|
S --> T[Build changed-files summary]
|
|
353
353
|
T --> U[Truncate each file with max_diff_lines]
|
|
354
|
-
U --> V[Run AI
|
|
355
|
-
V -->
|
|
356
|
-
W --> X[Preview review and suggested comments]
|
|
354
|
+
U --> V[Run single AI call: narrative review + structured comments]
|
|
355
|
+
V --> X[Preview review and suggested comments]
|
|
357
356
|
X --> Y{Dry-run enabled?}
|
|
358
357
|
Y -->|Yes| Z[Stop after preview]
|
|
359
358
|
Y -->|No| AA{Auto-post enabled?}
|
|
@@ -392,6 +391,7 @@ Options:
|
|
|
392
391
|
- `--format {terminal,markdown,json}`
|
|
393
392
|
- `--output`, `-o`
|
|
394
393
|
- `--no-color`
|
|
394
|
+
- `--debug-dump`
|
|
395
395
|
- `--model`, `-m`
|
|
396
396
|
- `--provider`, `-p`
|
|
397
397
|
- `--config`
|
|
@@ -428,3 +428,11 @@ tfs:
|
|
|
428
428
|
```
|
|
429
429
|
|
|
430
430
|
Avoid `verify_ssl: false` except for temporary troubleshooting.
|
|
431
|
+
|
|
432
|
+
### Debug Dump
|
|
433
|
+
|
|
434
|
+
```yaml
|
|
435
|
+
debug:
|
|
436
|
+
dump: true
|
|
437
|
+
dump_file: logs/llm_prompt_debug.log
|
|
438
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "code-review-ai-cli"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.4"
|
|
8
8
|
description = "Automated AI-powered code review CLI for Azure DevOps / TFS Pull Requests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -45,6 +45,7 @@ import os
|
|
|
45
45
|
import sys
|
|
46
46
|
import time
|
|
47
47
|
import threading
|
|
48
|
+
from datetime import datetime
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def _configure_console_streams() -> None:
|
|
@@ -254,6 +255,10 @@ def _add_global_options(parser: argparse.ArgumentParser) -> None:
|
|
|
254
255
|
"--no-color", action="store_true",
|
|
255
256
|
help="Disable terminal colors"
|
|
256
257
|
)
|
|
258
|
+
group_output.add_argument(
|
|
259
|
+
"--debug-dump", action="store_true",
|
|
260
|
+
help="Dump the diff and full LLM prompt/context sent to the LLM into a debug log file"
|
|
261
|
+
)
|
|
257
262
|
|
|
258
263
|
group_config = parser.add_argument_group("Configuration")
|
|
259
264
|
group_config.add_argument(
|
|
@@ -321,6 +326,8 @@ def run_review(args: argparse.Namespace) -> int:
|
|
|
321
326
|
config.dry_run = True
|
|
322
327
|
if getattr(args, "auto_post", False):
|
|
323
328
|
config.auto_post_comments = True
|
|
329
|
+
if getattr(args, "debug_dump", False):
|
|
330
|
+
config.debug_dump = True
|
|
324
331
|
|
|
325
332
|
# --- Validate configuration ---
|
|
326
333
|
issues = config.validate()
|
|
@@ -410,6 +417,12 @@ def run_pr_review_workflow(args: argparse.Namespace, config: ReviewConfig,
|
|
|
410
417
|
# Show PR details
|
|
411
418
|
print(formatter.format_pr_details(pr_details))
|
|
412
419
|
|
|
420
|
+
if config.debug_dump and not config.debug_dump_file:
|
|
421
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
422
|
+
config.debug_dump_file = os.path.join("logs", f"pr_{pr_id}_{timestamp}_debug.log")
|
|
423
|
+
os.makedirs(os.path.dirname(config.debug_dump_file), exist_ok=True)
|
|
424
|
+
print(formatter.format_info(f"🐛 Debug dump enabled: {config.debug_dump_file}"))
|
|
425
|
+
|
|
413
426
|
# --- Get PR diff ---
|
|
414
427
|
print(formatter.format_progress("Getting Pull Request diff"))
|
|
415
428
|
|
|
@@ -492,17 +505,7 @@ def run_pr_review_workflow(args: argparse.Namespace, config: ReviewConfig,
|
|
|
492
505
|
|
|
493
506
|
try:
|
|
494
507
|
llm = LLMClient(config)
|
|
495
|
-
|
|
496
|
-
# Get general review as text
|
|
497
|
-
review_text = llm.review(
|
|
498
|
-
diff=diff_truncated,
|
|
499
|
-
files_summary=files_summary,
|
|
500
|
-
context=getattr(args, "context", ""),
|
|
501
|
-
review_scope=config.review_scope,
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
# Get structured comments to post
|
|
505
|
-
structured_comments = llm.review_pr_structured(
|
|
508
|
+
review_text, structured_comments = llm.review_pr(
|
|
506
509
|
diff=diff_truncated,
|
|
507
510
|
files_summary=files_summary,
|
|
508
511
|
context=getattr(args, "context", ""),
|
|
@@ -103,6 +103,10 @@ class ReviewConfig:
|
|
|
103
103
|
output_file: str = "" # Path to save output
|
|
104
104
|
color_output: bool = True # Terminal colors
|
|
105
105
|
|
|
106
|
+
# --- Debug ----------------------------------------------------------
|
|
107
|
+
debug_dump: bool = False # Dump diff and full LLM prompt to a log file
|
|
108
|
+
debug_dump_file: str = "" # Custom path (empty = logs/pr_<id>_debug.log)
|
|
109
|
+
|
|
106
110
|
def get_effective_model(self) -> str:
|
|
107
111
|
"""Returns the effective model (configured or provider default)."""
|
|
108
112
|
if self.model:
|
|
@@ -222,6 +226,9 @@ class ReviewConfig:
|
|
|
222
226
|
"output_format": ("output", "format"),
|
|
223
227
|
"output_file": ("output", "file"),
|
|
224
228
|
"color_output": ("output", "color"),
|
|
229
|
+
# Debug
|
|
230
|
+
"debug_dump": ("debug", "dump"),
|
|
231
|
+
"debug_dump_file": ("debug", "dump_file"),
|
|
225
232
|
}
|
|
226
233
|
|
|
227
234
|
for attr, keys in mapping.items():
|
|
@@ -29,7 +29,7 @@ class LLMError(Exception):
|
|
|
29
29
|
SYSTEM_PROMPTS = {
|
|
30
30
|
"quick": {
|
|
31
31
|
"pt": (
|
|
32
|
-
"És um code reviewer experiente
|
|
32
|
+
"És um code reviewer senior experiente. Analisa o diff de código fornecido "
|
|
33
33
|
"e dá um review CONCISO e direto. Foca-te nos problemas mais críticos:\n"
|
|
34
34
|
"- Bugs e erros lógicos\n"
|
|
35
35
|
"- Problemas de segurança\n"
|
|
@@ -37,7 +37,7 @@ SYSTEM_PROMPTS = {
|
|
|
37
37
|
"Formato: Lista de bullet points com o ficheiro e linha quando possível. "
|
|
38
38
|
),
|
|
39
39
|
"en": (
|
|
40
|
-
"You are an experienced
|
|
40
|
+
"You are an experienced Senior Code Reviewer. Analyze the provided code diff "
|
|
41
41
|
"and give a CONCISE review. Focus on critical issues:\n"
|
|
42
42
|
"- Bugs and logic errors\n"
|
|
43
43
|
"- Security issues\n"
|
|
@@ -47,7 +47,7 @@ SYSTEM_PROMPTS = {
|
|
|
47
47
|
},
|
|
48
48
|
"detailed": {
|
|
49
49
|
"pt": (
|
|
50
|
-
"És um code reviewer experiente
|
|
50
|
+
"És um code reviewer senior experiente. Analisa o diff de código "
|
|
51
51
|
"fornecido e retorna apenas comentários inline.\n\n"
|
|
52
52
|
"Formato de output — para cada problema encontrado, escreve exatamente:\n"
|
|
53
53
|
"- Linha <número_linha>: <descrição do problema> \n\n"
|
|
@@ -59,7 +59,7 @@ SYSTEM_PROMPTS = {
|
|
|
59
59
|
"- Sê específico, objetivo e conciso. Responde em português."
|
|
60
60
|
),
|
|
61
61
|
"en": (
|
|
62
|
-
"You are an
|
|
62
|
+
"You are an experienced Senior Code Reviewer. Analyze the provided code and return only inline comments.\n\n"
|
|
63
63
|
"Output format — for each issue found, output exactly:\n"
|
|
64
64
|
"- Line <line_number>: <issue description>\n\n"
|
|
65
65
|
"Rules:\n"
|
|
@@ -72,7 +72,7 @@ SYSTEM_PROMPTS = {
|
|
|
72
72
|
},
|
|
73
73
|
"security": {
|
|
74
74
|
"pt": (
|
|
75
|
-
"És um especialista em segurança de aplicações
|
|
75
|
+
"És um especialista em segurança de aplicações. Analisa o diff "
|
|
76
76
|
"de código fornecido com foco EXCLUSIVO em segurança.\n\n"
|
|
77
77
|
"Procura por:\n"
|
|
78
78
|
"- SQL Injection\n"
|
|
@@ -90,7 +90,7 @@ SYSTEM_PROMPTS = {
|
|
|
90
90
|
"Responde em português."
|
|
91
91
|
),
|
|
92
92
|
"en": (
|
|
93
|
-
"You are an application security
|
|
93
|
+
"You are an application security. Analyze the "
|
|
94
94
|
"provided code diff with EXCLUSIVE focus on security.\n\n"
|
|
95
95
|
"Look for:\n"
|
|
96
96
|
"- SQL Injection\n"
|
|
@@ -109,44 +109,69 @@ SYSTEM_PROMPTS = {
|
|
|
109
109
|
},
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
# Special prompt for PR review with structured comments
|
|
112
|
+
# Special prompt for PR review with a combined narrative summary + structured comments
|
|
113
113
|
PR_COMMENT_PROMPT = {
|
|
114
114
|
"pt": (
|
|
115
|
-
"Analisa o diff de código de um Pull Request e retorna
|
|
116
|
-
"
|
|
117
|
-
'- "
|
|
118
|
-
'
|
|
119
|
-
'- "
|
|
120
|
-
'- "
|
|
121
|
-
'- "
|
|
122
|
-
'- "
|
|
115
|
+
"Analisa o diff de código de um Pull Request e retorna a tua resposta num único objeto JSON "
|
|
116
|
+
"com dois campos:\n"
|
|
117
|
+
'- "summary": um resumo narrativo do review, em texto (string). Se não encontrares problemas, '
|
|
118
|
+
'escreve algo como "Nenhum problema encontrado."\n'
|
|
119
|
+
'- "comments": um array de objetos, um por cada problema encontrado, com:\n'
|
|
120
|
+
' - "file": caminho do ficheiro (ex: "src/auth.py")\n'
|
|
121
|
+
' - "line": número da linha no diff (inteiro, ou 0 se geral)\n'
|
|
122
|
+
' - "type": tipo de issue ("bug", "security", "performance", "style", "suggestion", "praise")\n'
|
|
123
|
+
' - "comment": descrição direta do problema em português, sem saudações e sem emojis\n'
|
|
124
|
+
' - "suggestion": sugestão de correção (opcional, string vazia se não aplicável)\n'
|
|
125
|
+
' - "reference": fonte ou referência para o problema (URL de documentação, padrão ou princípio). '
|
|
126
|
+
'Importante: incluir SEMPRE uma referência relevante.\n\n'
|
|
123
127
|
"No campo 'comment', escreve de forma objetiva e curta. "
|
|
124
128
|
"Não uses introduções como 'Olá' ou 'Como code reviewer sénior'.\n"
|
|
125
129
|
"No campo 'reference', inclui uma fonte confiável, padrão ou link para documentação relevante.\n\n"
|
|
126
|
-
"Responde APENAS com um JSON
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
132
|
-
'
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
'
|
|
136
|
-
'
|
|
130
|
+
"Responde APENAS com um JSON object válido. Exemplo:\n"
|
|
131
|
+
'{\n'
|
|
132
|
+
' "summary": "Resumo narrativo curto do review...",\n'
|
|
133
|
+
' "comments": [\n'
|
|
134
|
+
' {\n'
|
|
135
|
+
' "file": "src/auth.py",\n'
|
|
136
|
+
' "line": 42,\n'
|
|
137
|
+
' "type": "security",\n'
|
|
138
|
+
' "comment": "Password armazenada em texto simples sem hashing",\n'
|
|
139
|
+
' "suggestion": "Usar bcrypt ou argon2 para hash de passwords",\n'
|
|
140
|
+
' "reference": "OWASP - Password Storage Cheat Sheet"\n'
|
|
141
|
+
' }\n'
|
|
142
|
+
' ]\n'
|
|
143
|
+
'}\n\n'
|
|
137
144
|
),
|
|
138
145
|
"en": (
|
|
139
|
-
"Analyze the Pull Request code diff and return your
|
|
140
|
-
"
|
|
141
|
-
'- "
|
|
142
|
-
'
|
|
143
|
-
'- "
|
|
144
|
-
'- "
|
|
145
|
-
'- "
|
|
146
|
-
'- "
|
|
146
|
+
"Analyze the Pull Request code diff and return your response as a single JSON object with two "
|
|
147
|
+
"fields:\n"
|
|
148
|
+
'- "summary": a narrative text summary of the review (string). If no issues are found, write '
|
|
149
|
+
'something like "No issues found."\n'
|
|
150
|
+
'- "comments": an array of objects, one per issue found, with:\n'
|
|
151
|
+
' - "file": file path (e.g., "src/auth.py")\n'
|
|
152
|
+
' - "line": line number in diff (integer, or 0 if general)\n'
|
|
153
|
+
' - "type": issue type ("bug", "security", "performance", "style", "suggestion", "praise")\n'
|
|
154
|
+
' - "comment": direct description of the issue, with no greetings and no emojis\n'
|
|
155
|
+
' - "suggestion": fix suggestion (optional, empty string if not applicable)\n'
|
|
156
|
+
' - "reference": source or reference for the issue (e.g., "OWASP Top 10", "PEP 8", documentation '
|
|
157
|
+
'URL, standard or principle). Important: ALWAYS include a relevant reference.\n\n'
|
|
147
158
|
"In 'comment', use a short and objective tone. "
|
|
148
159
|
"Do not include intros like 'Hello' or 'As a senior reviewer'.\n"
|
|
149
160
|
"In 'reference', include a trusted source, standard or link to relevant documentation.\n\n"
|
|
161
|
+
"Respond ONLY with a valid JSON object. Example:\n"
|
|
162
|
+
'{\n'
|
|
163
|
+
' "summary": "Short narrative summary of the review...",\n'
|
|
164
|
+
' "comments": [\n'
|
|
165
|
+
' {\n'
|
|
166
|
+
' "file": "src/auth.py",\n'
|
|
167
|
+
' "line": 42,\n'
|
|
168
|
+
' "type": "security",\n'
|
|
169
|
+
' "comment": "Password stored in plain text without hashing",\n'
|
|
170
|
+
' "suggestion": "Use bcrypt or argon2 to hash passwords",\n'
|
|
171
|
+
' "reference": "OWASP - Password Storage Cheat Sheet"\n'
|
|
172
|
+
' }\n'
|
|
173
|
+
' ]\n'
|
|
174
|
+
'}\n\n'
|
|
150
175
|
),
|
|
151
176
|
}
|
|
152
177
|
|
|
@@ -187,10 +212,11 @@ def get_pr_comment_prompt(language: str) -> str:
|
|
|
187
212
|
If the language key is not present, ``"pt"`` (Portuguese) is used as the
|
|
188
213
|
default.
|
|
189
214
|
|
|
190
|
-
The returned prompt instructs the LLM to produce a valid JSON
|
|
191
|
-
|
|
192
|
-
``
|
|
193
|
-
|
|
215
|
+
The returned prompt instructs the LLM to produce a single valid JSON
|
|
216
|
+
object with a ``"summary"`` field (narrative review text) and a
|
|
217
|
+
``"comments"`` field (array of review comment objects, each with the
|
|
218
|
+
fields ``file``, ``line``, ``type``, ``comment``, ``suggestion``,
|
|
219
|
+
and ``reference``).
|
|
194
220
|
|
|
195
221
|
Args:
|
|
196
222
|
language: Response language code — ``"en"`` for English,
|
|
@@ -210,8 +236,8 @@ def get_scope_guidance(review_scope: str, language: str, structured: bool = Fals
|
|
|
210
236
|
* Only added lines (``+``) appear in the diff section.
|
|
211
237
|
* A ``### FULL_FILE_CONTEXT_START: <path> ###`` /
|
|
212
238
|
``### FULL_FILE_CONTEXT_END ###`` block is embedded in the payload for
|
|
213
|
-
each changed file, containing the complete new-version
|
|
214
|
-
**read-only** background.
|
|
239
|
+
each changed file, containing the complete new-version (after changes)
|
|
240
|
+
file content as **read-only** background.
|
|
215
241
|
* The review must focus **exclusively** on the changed lines (``+``); the
|
|
216
242
|
full-file section exists only to prevent the model from hallucinating
|
|
217
243
|
about the surrounding code.
|
|
@@ -250,7 +276,7 @@ def get_scope_guidance(review_scope: str, language: str, structured: bool = Fals
|
|
|
250
276
|
if language == "en":
|
|
251
277
|
return (
|
|
252
278
|
"Review scope: diff_only. The diff contains only added lines (+) — context and deletions were removed. "
|
|
253
|
-
"
|
|
279
|
+
"The complete new-version file content, after the changes (between ### FULL_FILE_CONTEXT_START and ### FULL_FILE_CONTEXT_END markers) "
|
|
254
280
|
"is provided for each file as read-only context. "
|
|
255
281
|
"Use it to understand the surrounding code, but focus your review EXCLUSIVELY on the changed lines (marked + in the diff). "
|
|
256
282
|
"Do NOT report issues in unchanged lines unless they directly affect the correctness of the changes. "
|
|
@@ -259,8 +285,7 @@ def get_scope_guidance(review_scope: str, language: str, structured: bool = Fals
|
|
|
259
285
|
)
|
|
260
286
|
return (
|
|
261
287
|
"Ambito de review: diff_only. O diff contém apenas linhas adicionadas (+) — contexto e eliminações foram removidos. "
|
|
262
|
-
"
|
|
263
|
-
"é fornecida como contexto de leitura. "
|
|
288
|
+
"É fornecido o conteúdo completo do ficheiro na versão nova, depois das alterações (entre os marcadores ### FULL_FILE_CONTEXT_START e ### FULL_FILE_CONTEXT_END) como contexto de leitura. "
|
|
264
289
|
"Usa-a para compreender o código envolvente, mas foca o teu review EXCLUSIVAMENTE nas linhas alteradas (marcadas com + no diff). "
|
|
265
290
|
"NÃO reportes problemas em linhas não alteradas, exceto se afetarem diretamente a correção das alterações. "
|
|
266
291
|
"Para cada problema, DEVE ser fornecido file e line válidos (>0) para comentário inline. "
|
|
@@ -270,14 +295,14 @@ def get_scope_guidance(review_scope: str, language: str, structured: bool = Fals
|
|
|
270
295
|
if language == "en":
|
|
271
296
|
return (
|
|
272
297
|
"Review scope: diff_only. The diff contains only added lines (+). "
|
|
273
|
-
"
|
|
298
|
+
"The new-version (post-change) full file content (between ### FULL_FILE_CONTEXT_START and ### FULL_FILE_CONTEXT_END markers) "
|
|
274
299
|
"is provided for each file as read-only context. "
|
|
275
300
|
"Use it to understand the surrounding code, but focus your review EXCLUSIVELY on the changed lines. "
|
|
276
301
|
"Do NOT report issues in unchanged lines unless they directly affect the correctness of the changes."
|
|
277
302
|
)
|
|
278
303
|
return (
|
|
279
304
|
"Ambito de review: diff_only. O diff contém apenas linhas adicionadas (+). "
|
|
280
|
-
"Uma secção com o conteúdo completo do ficheiro (entre os marcadores ### FULL_FILE_CONTEXT_START e ### FULL_FILE_CONTEXT_END) "
|
|
305
|
+
"Uma secção com o conteúdo completo do ficheiro na versão nova, após as alterações (entre os marcadores ### FULL_FILE_CONTEXT_START e ### FULL_FILE_CONTEXT_END) "
|
|
281
306
|
"é fornecida como contexto de leitura. "
|
|
282
307
|
"Usa-a para compreender o código envolvente, mas foca o teu review EXCLUSIVAMENTE nas linhas alteradas. "
|
|
283
308
|
"NÃO reportes problemas em linhas não alteradas, exceto se afetarem diretamente a correção das alterações."
|
|
@@ -316,6 +341,34 @@ class LLMClient:
|
|
|
316
341
|
def __init__(self, config: ReviewConfig):
|
|
317
342
|
self.config = config
|
|
318
343
|
|
|
344
|
+
def _dump_prompt_debug(self, system_prompt: str, user_message: str) -> None:
|
|
345
|
+
"""
|
|
346
|
+
Appends the full prompt/context sent to the LLM to a debug log file.
|
|
347
|
+
|
|
348
|
+
Only active when ``config.debug_dump`` is enabled. The log may
|
|
349
|
+
contain source code and PR content, so it must never be committed
|
|
350
|
+
to version control.
|
|
351
|
+
"""
|
|
352
|
+
if not getattr(self.config, "debug_dump", False):
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
log_path = self.config.debug_dump_file or os.path.join("logs", "llm_prompt_debug.log")
|
|
356
|
+
log_dir = os.path.dirname(log_path)
|
|
357
|
+
if log_dir:
|
|
358
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
359
|
+
try:
|
|
360
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
361
|
+
f.write(f"\n{'=' * 80}\n")
|
|
362
|
+
f.write(f"[FULL CONTEXT SENT TO LLM] {datetime.datetime.now().isoformat()}\n")
|
|
363
|
+
f.write(f"Provider: {self.config.llm_provider} | Model: {self.config.get_effective_model()}\n")
|
|
364
|
+
f.write(f"{'=' * 80}\n")
|
|
365
|
+
f.write("--- SYSTEM PROMPT ---\n")
|
|
366
|
+
f.write(system_prompt + "\n")
|
|
367
|
+
f.write("--- USER MESSAGE ---\n")
|
|
368
|
+
f.write(user_message + "\n")
|
|
369
|
+
except OSError:
|
|
370
|
+
pass
|
|
371
|
+
|
|
319
372
|
def _load_custom_prompt_text(self) -> str:
|
|
320
373
|
"""Loads extra instructions from a configurable Markdown file."""
|
|
321
374
|
path = (self.config.custom_prompt_file or "").strip()
|
|
@@ -332,73 +385,30 @@ class LLMClient:
|
|
|
332
385
|
except Exception:
|
|
333
386
|
return ""
|
|
334
387
|
|
|
335
|
-
def
|
|
336
|
-
|
|
337
|
-
"""
|
|
338
|
-
Sends the diff to the LLM and returns the review as text.
|
|
388
|
+
def review_pr(self, diff: str, files_summary: list[dict],
|
|
389
|
+
context: str = "", review_scope: str = "diff_only") -> tuple[str, list[dict]]:
|
|
339
390
|
"""
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
)
|
|
344
|
-
custom_prompt = self._load_custom_prompt_text()
|
|
391
|
+
Sends the diff to the LLM in a single call and returns both the
|
|
392
|
+
narrative review text and the structured PR comments, parsed from one
|
|
393
|
+
combined JSON response.
|
|
345
394
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
structured=False,
|
|
350
|
-
)
|
|
395
|
+
This replaces the previous two-call approach (separate narrative and
|
|
396
|
+
structured-comments requests), halving the diff/context payload sent
|
|
397
|
+
to the LLM per PR review.
|
|
351
398
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
"Custom user instructions (follow with priority):\n"
|
|
358
|
-
f"{custom_prompt}"
|
|
359
|
-
)
|
|
360
|
-
merged_context = (
|
|
361
|
-
f"{context}\n\n[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
362
|
-
if context else
|
|
363
|
-
f"[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
364
|
-
)
|
|
365
|
-
else:
|
|
366
|
-
system_prompt = f"{base_prompt}\n\n{scope_guidance}"
|
|
367
|
-
merged_context = context
|
|
368
|
-
|
|
369
|
-
user_message = build_user_message(diff, files_summary, merged_context)
|
|
370
|
-
|
|
371
|
-
provider = self.config.llm_provider.lower()
|
|
372
|
-
|
|
373
|
-
if provider == "openai":
|
|
374
|
-
return self._call_openai(system_prompt, user_message)
|
|
375
|
-
elif provider == "azure_openai":
|
|
376
|
-
return self._call_openai(system_prompt, user_message, azure=True)
|
|
377
|
-
elif provider == "gemini":
|
|
378
|
-
return self._call_gemini(system_prompt, user_message)
|
|
379
|
-
elif provider == "claude":
|
|
380
|
-
return self._call_claude(system_prompt, user_message)
|
|
381
|
-
elif provider == "ollama":
|
|
382
|
-
return self._call_ollama(system_prompt, user_message)
|
|
383
|
-
elif provider == "copilot":
|
|
384
|
-
return self._call_copilot(system_prompt, user_message)
|
|
385
|
-
elif provider == "bedrock":
|
|
386
|
-
return self._call_bedrock(system_prompt, user_message)
|
|
387
|
-
else:
|
|
388
|
-
raise LLMError(
|
|
389
|
-
f"Unsupported provider: '{provider}'.\n"
|
|
390
|
-
"Available providers: openai, azure_openai, gemini, claude, ollama, copilot, bedrock"
|
|
391
|
-
)
|
|
399
|
+
Args:
|
|
400
|
+
diff: Unified diff (or filtered/truncated diff) to review.
|
|
401
|
+
files_summary: List of dicts with 'file', 'additions', 'deletions'.
|
|
402
|
+
context: Additional free-text context supplied by the user.
|
|
403
|
+
review_scope: "diff_only" (default) or "full_code".
|
|
392
404
|
|
|
393
|
-
def review_pr_structured(self, diff: str, files_summary: list[dict],
|
|
394
|
-
context: str = "", review_scope: str = "diff_only") -> list[dict]:
|
|
395
|
-
"""
|
|
396
|
-
Sends the diff to the LLM and returns structured PR comments.
|
|
397
|
-
|
|
398
405
|
Returns:
|
|
399
|
-
|
|
406
|
+
Tuple of (review_text, structured_comments), where structured_comments
|
|
407
|
+
is a list of dicts with keys: file, line, type, comment, suggestion,
|
|
408
|
+
reference.
|
|
400
409
|
"""
|
|
401
|
-
base_prompt =
|
|
410
|
+
base_prompt = get_system_prompt(self.config.verbosity, self.config.review_language)
|
|
411
|
+
json_schema_prompt = get_pr_comment_prompt(self.config.review_language)
|
|
402
412
|
custom_prompt = self._load_custom_prompt_text()
|
|
403
413
|
|
|
404
414
|
scope_guidance = get_scope_guidance(
|
|
@@ -407,10 +417,11 @@ class LLMClient:
|
|
|
407
417
|
structured=True,
|
|
408
418
|
)
|
|
409
419
|
|
|
420
|
+
combined_base = f"{base_prompt}\n\n{json_schema_prompt}\n\n{scope_guidance}"
|
|
421
|
+
|
|
410
422
|
if custom_prompt:
|
|
411
423
|
system_prompt = (
|
|
412
|
-
f"{
|
|
413
|
-
f"{scope_guidance}\n\n"
|
|
424
|
+
f"{combined_base}\n\n"
|
|
414
425
|
"---\n"
|
|
415
426
|
"Custom user instructions (follow with priority):\n"
|
|
416
427
|
f"{custom_prompt}"
|
|
@@ -421,10 +432,11 @@ class LLMClient:
|
|
|
421
432
|
f"[Custom context loaded from {self.config.custom_prompt_file}]"
|
|
422
433
|
)
|
|
423
434
|
else:
|
|
424
|
-
system_prompt =
|
|
435
|
+
system_prompt = combined_base
|
|
425
436
|
merged_context = context
|
|
426
437
|
|
|
427
438
|
user_message = build_user_message(diff, files_summary, merged_context)
|
|
439
|
+
self._dump_prompt_debug(system_prompt, user_message)
|
|
428
440
|
|
|
429
441
|
provider = self.config.llm_provider.lower()
|
|
430
442
|
|
|
@@ -443,13 +455,23 @@ class LLMClient:
|
|
|
443
455
|
elif provider == "bedrock":
|
|
444
456
|
raw = self._call_bedrock(system_prompt, user_message)
|
|
445
457
|
else:
|
|
446
|
-
raise LLMError(
|
|
458
|
+
raise LLMError(
|
|
459
|
+
f"Unsupported provider: '{provider}'.\n"
|
|
460
|
+
"Available providers: openai, azure_openai, gemini, claude, ollama, copilot, bedrock"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return self._parse_combined_response(raw)
|
|
447
464
|
|
|
448
|
-
|
|
465
|
+
def _extract_json_block(self, raw_response: str) -> str:
|
|
466
|
+
"""Strips markdown code fences and isolates the outer JSON object.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
raw_response: Raw text returned by the LLM, possibly wrapped in a
|
|
470
|
+
markdown code fence (```json ... ```` or ``` ... ````).
|
|
449
471
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
472
|
+
Returns:
|
|
473
|
+
The substring most likely to contain a valid JSON object.
|
|
474
|
+
"""
|
|
453
475
|
text = raw_response.strip()
|
|
454
476
|
if text.startswith("```"):
|
|
455
477
|
# Remove markdown code blocks
|
|
@@ -464,39 +486,62 @@ class LLMClient:
|
|
|
464
486
|
json_lines.append(line)
|
|
465
487
|
text = "\n".join(json_lines).strip()
|
|
466
488
|
|
|
467
|
-
# Try to find JSON
|
|
468
|
-
start = text.find("
|
|
469
|
-
end = text.rfind("
|
|
489
|
+
# Try to find JSON object
|
|
490
|
+
start = text.find("{")
|
|
491
|
+
end = text.rfind("}")
|
|
470
492
|
if start != -1 and end != -1:
|
|
471
493
|
text = text[start:end + 1]
|
|
472
494
|
|
|
495
|
+
return text
|
|
496
|
+
|
|
497
|
+
def _parse_combined_response(self, raw_response: str) -> tuple[str, list[dict]]:
|
|
498
|
+
"""Parses the combined LLM response into (summary, comments).
|
|
499
|
+
|
|
500
|
+
Falls back to treating the entire raw response as the summary (with an
|
|
501
|
+
empty comments list) when the response is not valid JSON or is not a
|
|
502
|
+
JSON object, mirroring the previous fallback behavior for malformed
|
|
503
|
+
structured responses.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
raw_response: Raw text returned by the LLM.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Tuple of (summary, comments) where comments is a list of dicts with
|
|
510
|
+
keys: file, line, type, comment, suggestion, reference.
|
|
511
|
+
"""
|
|
512
|
+
text = self._extract_json_block(raw_response)
|
|
513
|
+
|
|
473
514
|
try:
|
|
474
|
-
|
|
475
|
-
if not isinstance(comments, list):
|
|
476
|
-
comments = [comments]
|
|
515
|
+
data = json.loads(text)
|
|
477
516
|
except json.JSONDecodeError:
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
517
|
+
return raw_response, []
|
|
518
|
+
|
|
519
|
+
if not isinstance(data, dict):
|
|
520
|
+
return raw_response, []
|
|
521
|
+
|
|
522
|
+
summary = str(data.get("summary", "")).strip()
|
|
523
|
+
raw_comments = data.get("comments", [])
|
|
524
|
+
if not isinstance(raw_comments, list):
|
|
525
|
+
raw_comments = []
|
|
526
|
+
|
|
527
|
+
comments = []
|
|
528
|
+
for c in raw_comments:
|
|
529
|
+
if not isinstance(c, dict):
|
|
530
|
+
continue
|
|
531
|
+
try:
|
|
532
|
+
line = int(c.get("line", 0))
|
|
533
|
+
except (TypeError, ValueError):
|
|
534
|
+
line = 0
|
|
535
|
+
comments.append({
|
|
492
536
|
"file": str(c.get("file", "")),
|
|
493
|
-
"line":
|
|
537
|
+
"line": line,
|
|
494
538
|
"type": str(c.get("type", "suggestion")),
|
|
495
539
|
"comment": str(c.get("comment", "")),
|
|
496
540
|
"suggestion": str(c.get("suggestion", "")),
|
|
497
541
|
"reference": str(c.get("reference", "")),
|
|
498
542
|
})
|
|
499
|
-
|
|
543
|
+
|
|
544
|
+
return summary or raw_response, comments
|
|
500
545
|
|
|
501
546
|
# ------------------------------------------------------------------
|
|
502
547
|
# OpenAI / Azure OpenAI
|
|
@@ -81,7 +81,7 @@ review:
|
|
|
81
81
|
# Review scope:
|
|
82
82
|
# diff_only (default): focuses only on differences introduced in the PR
|
|
83
83
|
# full_code: allows full code review of changed files
|
|
84
|
-
scope:
|
|
84
|
+
scope: diff_only
|
|
85
85
|
|
|
86
86
|
# Markdown file with custom rules/context for the prompt
|
|
87
87
|
# The content of this file is injected into LLM instructions on each review
|
|
@@ -122,3 +122,7 @@ output:
|
|
|
122
122
|
|
|
123
123
|
# Terminal colors
|
|
124
124
|
color: true
|
|
125
|
+
|
|
126
|
+
debug:
|
|
127
|
+
dump: false
|
|
128
|
+
dump_file: "logs/debug.log"
|
|
@@ -300,17 +300,17 @@ class TFSClient:
|
|
|
300
300
|
Gets the diff of a specific Pull Request.
|
|
301
301
|
|
|
302
302
|
For ``diff_only`` scope (default), builds a standard unified diff for
|
|
303
|
-
each changed file and appends the full new-version
|
|
304
|
-
clearly-marked read-only block::
|
|
303
|
+
each changed file and appends the full new-version (source branch) file
|
|
304
|
+
content as a clearly-marked read-only block::
|
|
305
305
|
|
|
306
306
|
### FULL_FILE_CONTEXT_START: /path/to/file ###
|
|
307
|
-
<full file content>
|
|
307
|
+
<full file content — after changes>
|
|
308
308
|
### FULL_FILE_CONTEXT_END ###
|
|
309
309
|
|
|
310
310
|
This block is preserved by
|
|
311
311
|
:py:meth:`GitUtils.filter_diff_additions_only` so that the LLM
|
|
312
|
-
receives the
|
|
313
|
-
unchanged lines.
|
|
312
|
+
receives the complete resulting file as context, without being asked
|
|
313
|
+
to review unchanged lines.
|
|
314
314
|
|
|
315
315
|
For ``full_code`` scope, only the new-version file content is sent
|
|
316
316
|
(every line prefixed with ``+``), without a ``-`` baseline.
|
|
@@ -442,8 +442,9 @@ class TFSClient:
|
|
|
442
442
|
from the source branch, then generates a standard unified diff with
|
|
443
443
|
3 lines of context using :py:mod:`difflib`.
|
|
444
444
|
|
|
445
|
-
After the diff lines, the **full new-version
|
|
446
|
-
appended as a read-only context block bounded by
|
|
445
|
+
After the diff lines, the **full new-version (source branch, post-change)
|
|
446
|
+
file content** is appended as a read-only context block bounded by
|
|
447
|
+
sentinel markers::
|
|
447
448
|
|
|
448
449
|
### FULL_FILE_CONTEXT_START: /path/to/file ###
|
|
449
450
|
<complete file content — not a diff, not prefixed with +/->
|
|
@@ -451,8 +452,10 @@ class TFSClient:
|
|
|
451
452
|
|
|
452
453
|
These markers are recognised by
|
|
453
454
|
:py:meth:`GitUtils.filter_diff_additions_only`, which preserves every
|
|
454
|
-
line inside the block. This
|
|
455
|
-
|
|
455
|
+
line inside the block. This gives the LLM the complete resulting file
|
|
456
|
+
so it can correctly understand line numbers and surrounding code for
|
|
457
|
+
the added lines, avoiding confusion from showing stale pre-change
|
|
458
|
+
content.
|
|
456
459
|
|
|
457
460
|
Args:
|
|
458
461
|
repository: Repository name.
|
|
@@ -465,7 +468,7 @@ class TFSClient:
|
|
|
465
468
|
|
|
466
469
|
Returns:
|
|
467
470
|
List of diff strings for this file, including the
|
|
468
|
-
``FULL_FILE_CONTEXT`` block when
|
|
471
|
+
``FULL_FILE_CONTEXT`` block when old content is available.
|
|
469
472
|
"""
|
|
470
473
|
old_lines: list[str] = []
|
|
471
474
|
new_lines: list[str] = []
|
|
@@ -518,10 +521,12 @@ class TFSClient:
|
|
|
518
521
|
# so that filter_diff_by_extensions and _split_diff_sections work correctly.
|
|
519
522
|
result = [f"diff --git a{original_path} b{file_path}"] + diff
|
|
520
523
|
|
|
521
|
-
# Include
|
|
522
|
-
#
|
|
523
|
-
#
|
|
524
|
-
#
|
|
524
|
+
# Include new-version file content as read-only context for the LLM.
|
|
525
|
+
# Using the post-change (source branch) content gives the LLM the
|
|
526
|
+
# complete resulting file so it can correctly understand line numbers
|
|
527
|
+
# and surrounding code for the added lines, avoiding confusion from
|
|
528
|
+
# showing stale pre-change content.
|
|
529
|
+
# delete is excluded automatically because new_lines is empty for deleted files.
|
|
525
530
|
if new_lines:
|
|
526
531
|
result.append(f"### FULL_FILE_CONTEXT_START: {file_path} ###")
|
|
527
532
|
result.extend(new_lines)
|
|
@@ -192,10 +192,10 @@ def test_run_pr_review_workflow_dry_run_saves_output(mocker, review_config) -> N
|
|
|
192
192
|
tfs.get_pull_request_diff.return_value = diff
|
|
193
193
|
|
|
194
194
|
llm = MagicMock()
|
|
195
|
-
llm.
|
|
196
|
-
|
|
197
|
-
{"file": "a.py", "line": 1, "type": "bug", "comment": "msg"}
|
|
198
|
-
|
|
195
|
+
llm.review_pr.return_value = (
|
|
196
|
+
"General review",
|
|
197
|
+
[{"file": "a.py", "line": 1, "type": "bug", "comment": "msg"}],
|
|
198
|
+
)
|
|
199
199
|
|
|
200
200
|
git_utils = MagicMock()
|
|
201
201
|
git_utils.filter_diff_additions_only.return_value = diff
|
|
@@ -258,10 +258,8 @@ def test_run_pr_review_workflow_returns_error_when_posting_fails(mocker, review_
|
|
|
258
258
|
tfs.post_review_comments.side_effect = FakeTFSError("boom")
|
|
259
259
|
|
|
260
260
|
llm = MagicMock()
|
|
261
|
-
|
|
262
|
-
llm.
|
|
263
|
-
{"file": "a.py", "line": 1, "type": "bug", "comment": "msg"}
|
|
264
|
-
]
|
|
261
|
+
structured_comments = [{"file": "a.py", "line": 1, "type": "bug", "comment": "msg"}]
|
|
262
|
+
llm.review_pr.return_value = ("General review", structured_comments)
|
|
265
263
|
|
|
266
264
|
git_utils = MagicMock()
|
|
267
265
|
git_utils.filter_diff_additions_only.return_value = diff
|
|
@@ -273,7 +271,7 @@ def test_run_pr_review_workflow_returns_error_when_posting_fails(mocker, review_
|
|
|
273
271
|
mocker.patch("src.ai_review.ProgressIndicator", return_value=progress)
|
|
274
272
|
mocker.patch("src.ai_review.LLMClient", return_value=llm)
|
|
275
273
|
mocker.patch("src.ai_review.GitUtils.__new__", return_value=git_utils)
|
|
276
|
-
mocker.patch("src.ai_review._select_comments_to_post", return_value=
|
|
274
|
+
mocker.patch("src.ai_review._select_comments_to_post", return_value=structured_comments)
|
|
277
275
|
mocker.patch("src.ai_review._save_pr_review_output")
|
|
278
276
|
mocker.patch("src.tfs_client.TFSClient", return_value=tfs)
|
|
279
277
|
mocker.patch("src.tfs_client.TFSError", new=FakeTFSError)
|
|
@@ -89,7 +89,7 @@ def make_llm_config(**changes: object) -> ReviewConfig:
|
|
|
89
89
|
|
|
90
90
|
def test_prompt_helpers_select_expected_language_and_scope() -> None:
|
|
91
91
|
"""It should select prompts and scope guidance consistently."""
|
|
92
|
-
assert "code reviewer" in get_system_prompt("quick", "en")
|
|
92
|
+
assert "code reviewer" in get_system_prompt("quick", "en").lower()
|
|
93
93
|
assert "JSON" in get_pr_comment_prompt("pt")
|
|
94
94
|
assert "full_code" in get_scope_guidance("full_code", "en")
|
|
95
95
|
assert "file e line" in get_scope_guidance("diff_only", "pt", structured=True)
|
|
@@ -249,58 +249,95 @@ def test_load_custom_prompt_text_variants(tmp_path: Path, mocker) -> None:
|
|
|
249
249
|
assert client._load_custom_prompt_text() == ""
|
|
250
250
|
|
|
251
251
|
|
|
252
|
-
def
|
|
252
|
+
def test_review_pr_dispatches_and_merges_custom_prompt(mocker, tmp_path: Path) -> None:
|
|
253
253
|
"""It should route reviews to the configured provider and merge custom context."""
|
|
254
254
|
prompt_file = tmp_path / "prompt.md"
|
|
255
255
|
prompt_file.write_text("Always mention tests", encoding="utf-8")
|
|
256
256
|
config = make_llm_config(custom_prompt_file=str(prompt_file))
|
|
257
257
|
client = LLMClient(config)
|
|
258
|
-
openai = mocker.patch(
|
|
258
|
+
openai = mocker.patch(
|
|
259
|
+
"src.llm_client.LLMClient._call_openai",
|
|
260
|
+
return_value='{"summary": "review text", "comments": []}',
|
|
261
|
+
)
|
|
259
262
|
|
|
260
|
-
result = client.
|
|
263
|
+
result = client.review_pr("+code", [{"file": "a.py", "additions": 1, "deletions": 0}], context="Focus on bugs")
|
|
261
264
|
|
|
262
|
-
assert result == "review text"
|
|
265
|
+
assert result == ("review text", [])
|
|
263
266
|
system_prompt, user_message = openai.call_args.args[:2]
|
|
264
267
|
assert "Custom user instructions" in system_prompt
|
|
265
268
|
assert "Custom context loaded from" in user_message
|
|
266
269
|
|
|
267
270
|
|
|
268
|
-
def
|
|
271
|
+
def test_review_pr_raises_for_unsupported_provider() -> None:
|
|
269
272
|
"""It should reject unsupported providers before any HTTP call."""
|
|
270
273
|
client = LLMClient(make_llm_config(llm_provider="unknown"))
|
|
271
274
|
|
|
272
275
|
with pytest.raises(LLMError, match="Unsupported provider"):
|
|
273
|
-
client.
|
|
276
|
+
client.review_pr("+code", [])
|
|
274
277
|
|
|
275
278
|
|
|
276
|
-
def
|
|
277
|
-
"""It should dispatch
|
|
279
|
+
def test_review_pr_dispatches_and_parses_combined_response(mocker) -> None:
|
|
280
|
+
"""It should dispatch a single combined review and normalize summary + JSON comments."""
|
|
278
281
|
client = LLMClient(make_llm_config(llm_provider="copilot"))
|
|
279
282
|
copilot = mocker.patch(
|
|
280
283
|
"src.llm_client.LLMClient._call_copilot",
|
|
281
|
-
return_value=
|
|
284
|
+
return_value=(
|
|
285
|
+
'{"summary": "General review", "comments": '
|
|
286
|
+
'[{"file": "src/app.py", "line": 5, "type": "bug", "comment": "boom", '
|
|
287
|
+
'"suggestion": "fix", "reference": "Docs"}]}'
|
|
288
|
+
),
|
|
282
289
|
)
|
|
283
290
|
|
|
284
|
-
comments = client.
|
|
291
|
+
summary, comments = client.review_pr("+code", [{"file": "a.py", "additions": 1, "deletions": 0}])
|
|
285
292
|
|
|
286
293
|
assert copilot.called
|
|
294
|
+
assert summary == "General review"
|
|
287
295
|
assert comments == [{"file": "src/app.py", "line": 5, "type": "bug", "comment": "boom", "suggestion": "fix", "reference": "Docs"}]
|
|
288
296
|
|
|
289
297
|
|
|
290
|
-
def
|
|
291
|
-
"""It should parse code fences,
|
|
298
|
+
def test_parse_combined_response_handles_markdown_missing_fields_and_invalid_json() -> None:
|
|
299
|
+
"""It should parse code fences, tolerate missing/invalid fields and fall back on invalid payloads."""
|
|
292
300
|
client = LLMClient(make_llm_config())
|
|
293
|
-
|
|
294
|
-
|
|
301
|
+
|
|
302
|
+
fenced_summary, fenced_comments = client._parse_combined_response(
|
|
303
|
+
"```json\n{\"summary\": \"ok\", \"comments\": [{\"file\": \"a.py\", \"line\": 1, \"comment\": \"x\"}]}\n```"
|
|
295
304
|
)
|
|
296
|
-
|
|
297
|
-
|
|
305
|
+
assert fenced_summary == "ok"
|
|
306
|
+
assert fenced_comments[0]["file"] == "a.py"
|
|
307
|
+
assert fenced_comments[0]["line"] == 1
|
|
308
|
+
|
|
309
|
+
# Missing "comments" field defaults to an empty list.
|
|
310
|
+
no_comments_summary, no_comments = client._parse_combined_response(
|
|
311
|
+
'{"summary": "only summary"}'
|
|
298
312
|
)
|
|
299
|
-
|
|
313
|
+
assert no_comments_summary == "only summary"
|
|
314
|
+
assert no_comments == []
|
|
300
315
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
316
|
+
# Non-list "comments" field is treated as if no comments were provided.
|
|
317
|
+
bad_comments_summary, bad_comments = client._parse_combined_response(
|
|
318
|
+
'{"summary": "weird", "comments": "not a list"}'
|
|
319
|
+
)
|
|
320
|
+
assert bad_comments_summary == "weird"
|
|
321
|
+
assert bad_comments == []
|
|
322
|
+
|
|
323
|
+
# Non-dict JSON value (e.g. a bare array with no nested object) falls back
|
|
324
|
+
# to the raw response text as the summary, with no comments.
|
|
325
|
+
raw_array = "[1, 2, 3]"
|
|
326
|
+
array_summary, array_comments = client._parse_combined_response(raw_array)
|
|
327
|
+
assert array_summary == raw_array
|
|
328
|
+
assert array_comments == []
|
|
329
|
+
|
|
330
|
+
# Malformed JSON falls back to raw text as summary with no comments.
|
|
331
|
+
fallback_summary, fallback_comments = client._parse_combined_response("not json at all")
|
|
332
|
+
assert fallback_summary == "not json at all"
|
|
333
|
+
assert fallback_comments == []
|
|
334
|
+
|
|
335
|
+
# A non-numeric "line" value must not crash parsing; it defaults to 0.
|
|
336
|
+
bad_line_summary, bad_line_comments = client._parse_combined_response(
|
|
337
|
+
'{"summary": "ok", "comments": [{"file": "a.py", "line": "N/A", "comment": "z"}]}'
|
|
338
|
+
)
|
|
339
|
+
assert bad_line_summary == "ok"
|
|
340
|
+
assert bad_line_comments[0]["line"] == 0
|
|
304
341
|
|
|
305
342
|
|
|
306
343
|
def test_call_openai_builds_expected_payload_and_validates_configuration(mocker) -> None:
|
|
@@ -354,7 +354,7 @@ def test_build_diff_parts_and_file_content(mocker) -> None:
|
|
|
354
354
|
# ---------------------------------------------------------------------------
|
|
355
355
|
|
|
356
356
|
def test_build_unified_diff_part_appends_full_file_context_block(mocker) -> None:
|
|
357
|
-
"""_build_unified_diff_part must append FULL_FILE_CONTEXT markers with the new file content."""
|
|
357
|
+
"""_build_unified_diff_part must append FULL_FILE_CONTEXT markers with the new (source branch, post-change) file content."""
|
|
358
358
|
client = TFSClient(make_tfs_config())
|
|
359
359
|
mocker.patch(
|
|
360
360
|
"src.tfs_client.TFSClient._get_file_content",
|
|
@@ -366,15 +366,36 @@ def test_build_unified_diff_part_appends_full_file_context_block(mocker) -> None
|
|
|
366
366
|
"refs/heads/feature", "refs/heads/main",
|
|
367
367
|
)
|
|
368
368
|
joined = "\n".join(result)
|
|
369
|
+
context_block = joined.split("### FULL_FILE_CONTEXT_START")[1]
|
|
369
370
|
|
|
370
371
|
assert "### FULL_FILE_CONTEXT_START: /src/app.py ###" in joined
|
|
371
|
-
assert "new line one" in
|
|
372
|
-
assert "new line two" in
|
|
372
|
+
assert "new line one" in context_block
|
|
373
|
+
assert "new line two" in context_block
|
|
374
|
+
assert "old line" not in context_block
|
|
373
375
|
assert "### FULL_FILE_CONTEXT_END ###" in joined
|
|
374
376
|
# Context block must appear after the diff headers
|
|
375
377
|
assert joined.index("### FULL_FILE_CONTEXT_START") > joined.index("diff --git")
|
|
376
378
|
|
|
377
379
|
|
|
380
|
+
def test_build_unified_diff_part_context_block_present_on_add(mocker) -> None:
|
|
381
|
+
"""_build_unified_diff_part must append a FULL_FILE_CONTEXT block for add, containing the new content."""
|
|
382
|
+
client = TFSClient(make_tfs_config())
|
|
383
|
+
mocker.patch(
|
|
384
|
+
"src.tfs_client.TFSClient._get_file_content",
|
|
385
|
+
return_value="new content",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
result = client._build_unified_diff_part(
|
|
389
|
+
"repo-a", "/src/new_file.py", "/src/new_file.py", "add",
|
|
390
|
+
"refs/heads/feature", "refs/heads/main",
|
|
391
|
+
)
|
|
392
|
+
joined = "\n".join(result)
|
|
393
|
+
|
|
394
|
+
assert "### FULL_FILE_CONTEXT_START: /src/new_file.py ###" in joined
|
|
395
|
+
assert "new content" in joined
|
|
396
|
+
assert "### FULL_FILE_CONTEXT_END ###" in joined
|
|
397
|
+
|
|
398
|
+
|
|
378
399
|
def test_build_unified_diff_part_no_context_block_on_delete(mocker) -> None:
|
|
379
400
|
"""_build_unified_diff_part must NOT append a FULL_FILE_CONTEXT block when change_type is delete."""
|
|
380
401
|
client = TFSClient(make_tfs_config())
|
|
@@ -394,7 +415,8 @@ def test_build_unified_diff_part_no_context_block_on_delete(mocker) -> None:
|
|
|
394
415
|
|
|
395
416
|
|
|
396
417
|
def test_build_unified_diff_part_context_block_uses_new_file_path(mocker) -> None:
|
|
397
|
-
"""The FULL_FILE_CONTEXT_START marker must reference the new file path (file_path), not original_path
|
|
418
|
+
"""The FULL_FILE_CONTEXT_START marker must reference the new file path (file_path), not original_path,
|
|
419
|
+
and the block content must be the new (renamed) file's content."""
|
|
398
420
|
client = TFSClient(make_tfs_config())
|
|
399
421
|
mocker.patch(
|
|
400
422
|
"src.tfs_client.TFSClient._get_file_content",
|
|
@@ -406,9 +428,12 @@ def test_build_unified_diff_part_context_block_uses_new_file_path(mocker) -> Non
|
|
|
406
428
|
"refs/heads/feature", "refs/heads/main",
|
|
407
429
|
)
|
|
408
430
|
joined = "\n".join(result)
|
|
431
|
+
context_block = joined.split("### FULL_FILE_CONTEXT_START")[1]
|
|
409
432
|
|
|
410
433
|
assert "### FULL_FILE_CONTEXT_START: /src/renamed.py ###" in joined
|
|
411
434
|
assert "### FULL_FILE_CONTEXT_START: /src/original.py ###" not in joined
|
|
435
|
+
assert "new content" in context_block
|
|
436
|
+
assert "old content" not in context_block
|
|
412
437
|
|
|
413
438
|
|
|
414
439
|
def test_build_unified_diff_part_no_context_block_when_content_unavailable(mocker) -> None:
|
{code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/requires.txt
RENAMED
|
File without changes
|
{code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|