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.
Files changed (25) hide show
  1. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/PKG-INFO +12 -4
  2. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/README.md +12 -4
  3. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/PKG-INFO +12 -4
  4. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/pyproject.toml +1 -1
  5. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/ai_review.py +14 -11
  6. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/config.py +7 -0
  7. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/llm_client.py +181 -136
  8. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/prompts/config.yaml.template +5 -1
  9. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/tfs_client.py +19 -14
  10. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_ai_review.py +7 -9
  11. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_llm_client.py +58 -21
  12. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_tfs_client.py +29 -4
  13. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/SOURCES.txt +0 -0
  14. {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
  15. {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
  16. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/code_review_ai_cli.egg-info/requires.txt +0 -0
  17. {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
  18. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/setup.cfg +0 -0
  19. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/__init__.py +0 -0
  20. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/formatter.py +0 -0
  21. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/git_utils.py +0 -0
  22. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/src/prompts/review_prompt.md.template +0 -0
  23. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_config.py +0 -0
  24. {code_review_ai_cli-1.3.2 → code_review_ai_cli-1.3.4}/tests/test_formatter.py +0 -0
  25. {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.2
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 general review]
355
- V --> W[Run AI structured comment generation]
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 general review]
318
- V --> W[Run AI structured comment generation]
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.2
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 general review]
355
- V --> W[Run AI structured comment generation]
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.2"
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 em .Net C#, TypeScript e SQL. Analisa o diff de código fornecido "
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 .Net C#, TypeScript and SQL code reviewer. Analyze the provided code diff "
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 em .Net C#, TypeScript e SQL. Analisa o diff de código "
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 expert .Net C#, TypeScript e SQL code reviewer. Analyze the provided code and return only inline comments.\n\n"
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 .Net C#, TypeScript e SQL. Analisa o diff "
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 .Net C#, TypeScript and SQL specialist. Analyze the "
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 os teus comentários em formato JSON estruturado.\n\n"
116
- "Para CADA problema encontrado, retorna um objeto JSON com:\n"
117
- '- "file": caminho do ficheiro (ex: "src/auth.py")\n'
118
- '- "line": número da linha no diff (inteiro, ou 0 se geral)\n'
119
- '- "type": tipo de issue ("bug", "security", "performance", "style", "suggestion", "praise")\n'
120
- '- "comment": descrição direta do problema em português, sem saudações e sem emojis\n'
121
- '- "suggestion": sugestão de correção (opcional, string vazia se não aplicável)\n'
122
- '- "reference": fonte ou referência para o problema (URL de documentação, padrão ou princípio). Importante: incluir SEMPRE uma referência relevante.\n\n'
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 array válido. Exemplo:\n"
127
- '[\n'
128
- ' {\n'
129
- ' "file": "src/auth.py",\n'
130
- ' "line": 42,\n'
131
- ' "type": "security",\n'
132
- ' "comment": "Password armazenada em texto simples sem hashing",\n'
133
- ' "suggestion": "Usar bcrypt ou argon2 para hash de passwords",\n'
134
- ' "reference": "OWASP - Password Storage Cheat Sheet"\n'
135
- ' }\n'
136
- ']\n\n'
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 comments in structured JSON format.\n\n"
140
- "For EACH issue found, return a JSON object with:\n"
141
- '- "file": file path (e.g., "src/auth.py")\n'
142
- '- "line": line number in diff (integer, or 0 if general)\n'
143
- '- "type": issue type ("bug", "security", "performance", "style", "suggestion", "praise")\n'
144
- '- "comment": direct description of the issue, with no greetings and no emojis\n'
145
- '- "suggestion": fix suggestion (optional, empty string if not applicable)\n'
146
- '- "reference": source or reference for the issue (e.g., "OWASP Top 10", "PEP 8", documentation URL, standard or principle). Important: ALWAYS include a relevant reference.\n\n'
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 array where
191
- each element represents a single review comment with the fields
192
- ``file``, ``line``, ``type``, ``comment``, ``suggestion``,
193
- and ``reference``.
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 file content as
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
- "A full file content section (between ### FULL_FILE_CONTEXT_START and ### FULL_FILE_CONTEXT_END markers) "
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
- "Uma secção com o conteúdo completo do ficheiro (entre os marcadores ### FULL_FILE_CONTEXT_START e ### FULL_FILE_CONTEXT_END) "
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
- "A full file content section (between ### FULL_FILE_CONTEXT_START and ### FULL_FILE_CONTEXT_END markers) "
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 review(self, diff: str, files_summary: list[dict],
336
- context: str = "", review_scope: str = "diff_only") -> str:
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
- base_prompt = get_system_prompt(
341
- self.config.verbosity,
342
- self.config.review_language,
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
- scope_guidance = get_scope_guidance(
347
- review_scope=review_scope,
348
- language=self.config.review_language,
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
- if custom_prompt:
353
- system_prompt = (
354
- f"{base_prompt}\n\n"
355
- f"{scope_guidance}\n\n"
356
- "---\n"
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
- List of dicts with keys: file, line, type, comment, suggestion
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 = get_pr_comment_prompt(self.config.review_language)
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"{base_prompt}\n\n"
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 = f"{base_prompt}\n\n{scope_guidance}"
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(f"Unsupported provider: '{provider}'")
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
- return self._parse_structured_comments(raw)
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
- def _parse_structured_comments(self, raw_response: str) -> list[dict]:
451
- """Parses the LLM JSON response."""
452
- # Try to extract JSON from possible markdown
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 array
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
- comments = json.loads(text)
475
- if not isinstance(comments, list):
476
- comments = [comments]
515
+ data = json.loads(text)
477
516
  except json.JSONDecodeError:
478
- # Fallback: return as a general comment
479
- return [{
480
- "file": "",
481
- "line": 0,
482
- "type": "suggestion",
483
- "comment": raw_response,
484
- "suggestion": "",
485
- "reference": "",
486
- }]
487
-
488
- # Validate and normalize each comment
489
- validated = []
490
- for c in comments:
491
- validated.append({
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": int(c.get("line", 0)),
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
- return validated
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: full_code
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 file content as a
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 surrounding context without being asked to review
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 file content** is
446
- appended as a read-only context block bounded by sentinel markers::
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 lets the LLM understand surrounding code
455
- without being asked to review unchanged lines.
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 new content is available.
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 full file content as read-only context for the LLM.
522
- # This section is preserved by filter_diff_additions_only and allows
523
- # the model to understand the surrounding code without reviewing
524
- # unchanged lines.
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.review.return_value = "General review"
196
- llm.review_pr_structured.return_value = [
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
- llm.review.return_value = "General review"
262
- llm.review_pr_structured.return_value = [
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=llm.review_pr_structured.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 test_review_dispatches_and_merges_custom_prompt(mocker, tmp_path: Path) -> None:
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("src.llm_client.LLMClient._call_openai", return_value="review text")
258
+ openai = mocker.patch(
259
+ "src.llm_client.LLMClient._call_openai",
260
+ return_value='{"summary": "review text", "comments": []}',
261
+ )
259
262
 
260
- result = client.review("+code", [{"file": "a.py", "additions": 1, "deletions": 0}], context="Focus on bugs")
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 test_review_raises_for_unsupported_provider() -> None:
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.review("+code", [])
276
+ client.review_pr("+code", [])
274
277
 
275
278
 
276
- def test_review_pr_structured_dispatches_and_parses(mocker) -> None:
277
- """It should dispatch structured reviews and normalize JSON comments."""
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='[{"file": "src/app.py", "line": 5, "type": "bug", "comment": "boom", "suggestion": "fix", "reference": "Docs"}]',
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.review_pr_structured("+code", [{"file": "a.py", "additions": 1, "deletions": 0}])
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 test_parse_structured_comments_handles_markdown_single_object_and_invalid_json() -> None:
291
- """It should parse code fences, single objects and invalid fallback payloads."""
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
- fenced = client._parse_structured_comments(
294
- "```json\n[{\"file\": \"a.py\", \"line\": 1, \"comment\": \"x\"}]\n```"
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
- single = client._parse_structured_comments(
297
- '{"file": "a.py", "line": 2, "type": "style", "comment": "y"}'
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
- fallback = client._parse_structured_comments("not json at all")
313
+ assert no_comments_summary == "only summary"
314
+ assert no_comments == []
300
315
 
301
- assert fenced[0]["file"] == "a.py"
302
- assert single[0]["line"] == 2
303
- assert fallback[0]["comment"] == "not json at all"
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 joined
372
- assert "new line two" in joined
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: