code-review-ai-cli 2.2.3__tar.gz → 2.2.5__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 (31) hide show
  1. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/PKG-INFO +4 -9
  2. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/README.md +3 -8
  3. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/PKG-INFO +4 -9
  4. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/pyproject.toml +1 -1
  5. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/ai_review.py +9 -2
  6. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/formatter.py +3 -1
  7. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/llm_client.py +44 -30
  8. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/prompts/config.yaml.template +1 -1
  9. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/tfs_client.py +129 -7
  10. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_formatter.py +2 -1
  11. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_llm_client.py +48 -10
  12. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_tfs_client.py +111 -0
  13. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/SOURCES.txt +0 -0
  14. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/dependency_links.txt +0 -0
  15. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/entry_points.txt +0 -0
  16. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/requires.txt +0 -0
  17. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/top_level.txt +0 -0
  18. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/setup.cfg +0 -0
  19. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/__init__.py +0 -0
  20. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/config.py +0 -0
  21. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/context_extractor.py +0 -0
  22. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/git_utils.py +0 -0
  23. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/prompt_utils.py +0 -0
  24. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/prompts/review_prompt.md.template +0 -0
  25. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/token_estimator.py +0 -0
  26. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_ai_review.py +0 -0
  27. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_config.py +0 -0
  28. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_context_extractor.py +0 -0
  29. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_git_utils.py +0 -0
  30. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_prompt_utils.py +0 -0
  31. {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_token_estimator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-review-ai-cli
3
- Version: 2.2.3
3
+ Version: 2.2.5
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
@@ -55,7 +55,8 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
55
55
  - Configuration exclusively via `config.yaml`
56
56
  - Providers LLM: OpenAI, Azure OpenAI, Gemini, Claude, Ollama, GitHub Copilot, AWS Bedrock
57
57
  - Diff filtering by **excluded path prefixes** (`excluded_paths`) and/or **file extensions** (`file_extensions_filter`)
58
- - Prompt token estimation with LiteLLM (`Total Prompt`)
58
+ - Prompt token consumption estimation with LiteLLM (`Total Tokens`)
59
+ - Validation to ensure comments are not duplicated in re-runs (CI/CD Pipelines)
59
60
 
60
61
  ## Documentation
61
62
 
@@ -68,12 +69,6 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
68
69
 
69
70
  ![Review Output Format Example](/imgs/review_output.png)
70
71
 
71
- During a PR review, the terminal also displays `Total Prompt`, an estimate of
72
- the input tokens in the complete payload sent to the configured provider. The
73
- provider comes from `llm.provider` and the model comes from `llm.model` or its
74
- default one. If LiteLLM cannot resolve the provider/model combination,
75
- the tool tries the model without a provider prefix and then uses local
76
- `tiktoken` as a final fallback.
77
72
 
78
73
  ## Installation
79
74
 
@@ -157,5 +152,5 @@ output:
157
152
  - `AI Review: List Active PRs`
158
153
  - `AI Review: Interactive Mode`
159
154
 
160
- ## Licence
155
+ ## License
161
156
  This project is licensed under the Apache Licence 2.0 — see the [LICENSE](/License).
@@ -11,7 +11,8 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
11
11
  - Configuration exclusively via `config.yaml`
12
12
  - Providers LLM: OpenAI, Azure OpenAI, Gemini, Claude, Ollama, GitHub Copilot, AWS Bedrock
13
13
  - Diff filtering by **excluded path prefixes** (`excluded_paths`) and/or **file extensions** (`file_extensions_filter`)
14
- - Prompt token estimation with LiteLLM (`Total Prompt`)
14
+ - Prompt token consumption estimation with LiteLLM (`Total Tokens`)
15
+ - Validation to ensure comments are not duplicated in re-runs (CI/CD Pipelines)
15
16
 
16
17
  ## Documentation
17
18
 
@@ -24,12 +25,6 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
24
25
 
25
26
  ![Review Output Format Example](/imgs/review_output.png)
26
27
 
27
- During a PR review, the terminal also displays `Total Prompt`, an estimate of
28
- the input tokens in the complete payload sent to the configured provider. The
29
- provider comes from `llm.provider` and the model comes from `llm.model` or its
30
- default one. If LiteLLM cannot resolve the provider/model combination,
31
- the tool tries the model without a provider prefix and then uses local
32
- `tiktoken` as a final fallback.
33
28
 
34
29
  ## Installation
35
30
 
@@ -113,5 +108,5 @@ output:
113
108
  - `AI Review: List Active PRs`
114
109
  - `AI Review: Interactive Mode`
115
110
 
116
- ## Licence
111
+ ## License
117
112
  This project is licensed under the Apache Licence 2.0 — see the [LICENSE](/License).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-review-ai-cli
3
- Version: 2.2.3
3
+ Version: 2.2.5
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
@@ -55,7 +55,8 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
55
55
  - Configuration exclusively via `config.yaml`
56
56
  - Providers LLM: OpenAI, Azure OpenAI, Gemini, Claude, Ollama, GitHub Copilot, AWS Bedrock
57
57
  - Diff filtering by **excluded path prefixes** (`excluded_paths`) and/or **file extensions** (`file_extensions_filter`)
58
- - Prompt token estimation with LiteLLM (`Total Prompt`)
58
+ - Prompt token consumption estimation with LiteLLM (`Total Tokens`)
59
+ - Validation to ensure comments are not duplicated in re-runs (CI/CD Pipelines)
59
60
 
60
61
  ## Documentation
61
62
 
@@ -68,12 +69,6 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
68
69
 
69
70
  ![Review Output Format Example](/imgs/review_output.png)
70
71
 
71
- During a PR review, the terminal also displays `Total Prompt`, an estimate of
72
- the input tokens in the complete payload sent to the configured provider. The
73
- provider comes from `llm.provider` and the model comes from `llm.model` or its
74
- default one. If LiteLLM cannot resolve the provider/model combination,
75
- the tool tries the model without a provider prefix and then uses local
76
- `tiktoken` as a final fallback.
77
72
 
78
73
  ## Installation
79
74
 
@@ -157,5 +152,5 @@ output:
157
152
  - `AI Review: List Active PRs`
158
153
  - `AI Review: Interactive Mode`
159
154
 
160
- ## Licence
155
+ ## License
161
156
  This project is licensed under the Apache Licence 2.0 — see the [LICENSE](/License).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "code-review-ai-cli"
7
- version = "2.2.3"
7
+ version = "2.2.5"
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"
@@ -595,8 +595,15 @@ def run_pr_review_workflow(args: argparse.Namespace, config: ReviewConfig,
595
595
  f"---\n"
596
596
  f"*Automatic review generated by AI Code Review v{VERSION}*"
597
597
  )
598
- tfs.post_general_comment(repo_name, pr_id, summary_comment)
599
- print(formatter.format_success("General summary posted to PR."))
598
+ result = tfs.post_general_comment(
599
+ repo_name, pr_id, summary_comment, dedup_marker="summary"
600
+ )
601
+ if result.get("skipped"):
602
+ print(formatter.format_info(
603
+ "General summary already present, skipped."
604
+ ))
605
+ else:
606
+ print(formatter.format_success("General summary posted to PR."))
600
607
  except TFSError as exc:
601
608
  print(formatter.format_warning(f"Could not post general summary: {exc}"))
602
609
 
@@ -297,7 +297,8 @@ class ReviewFormatter:
297
297
  lines = [f"\n{c.BOLD}📤 Comment posting results:{c.RESET}\n"]
298
298
 
299
299
  success_count = sum(1 for r in results if r.get("success"))
300
- fail_count = len(results) - success_count
300
+ skipped_count = sum(1 for r in results if r.get("skipped"))
301
+ fail_count = len(results) - success_count - skipped_count
301
302
 
302
303
  for r in results:
303
304
  if r.get("success"):
@@ -322,6 +323,7 @@ class ReviewFormatter:
322
323
  lines.append(
323
324
  f"\n {c.BOLD}Total: "
324
325
  f"{c.GREEN}{success_count} posted{c.RESET}, "
326
+ f"{c.YELLOW}{skipped_count} skipped{c.RESET}, "
325
327
  f"{c.RED}{fail_count} failed{c.RESET}"
326
328
  )
327
329
  return "\n".join(lines)
@@ -591,8 +591,9 @@ class LLMClient:
591
591
  {"role": "user", "content": user_message},
592
592
  ],
593
593
  "max_tokens": self.config.max_tokens,
594
- "temperature": self.config.temperature,
595
594
  }
595
+ if self.config.temperature != 99:
596
+ payload["temperature"] = self.config.temperature
596
597
 
597
598
  return self._http_openai_compatible(url, headers, payload)
598
599
 
@@ -637,10 +638,11 @@ class LLMClient:
637
638
  "parts": [{"text": system_prompt}]
638
639
  },
639
640
  "generationConfig": {
640
- "temperature": self.config.temperature,
641
641
  "maxOutputTokens": self.config.max_tokens,
642
642
  },
643
643
  }
644
+ if self.config.temperature != 99:
645
+ payload["generationConfig"]["temperature"] = self.config.temperature
644
646
 
645
647
  try:
646
648
  resp = requests.post(url, headers=headers, json=payload, timeout=180)
@@ -715,12 +717,13 @@ class LLMClient:
715
717
  payload = {
716
718
  "model": model,
717
719
  "max_tokens": self.config.max_tokens,
718
- "temperature": self.config.temperature,
719
720
  "system": system_prompt,
720
721
  "messages": [
721
722
  {"role": "user", "content": user_message},
722
723
  ],
723
724
  }
725
+ if self.config.temperature != 99:
726
+ payload["temperature"] = self.config.temperature
724
727
 
725
728
  try:
726
729
  resp = requests.post(url, headers=headers, json=payload, timeout=180)
@@ -793,9 +796,10 @@ class LLMClient:
793
796
  {"role": "system", "content": system_prompt},
794
797
  {"role": "user", "content": user_message},
795
798
  ],
796
- "temperature": self.config.temperature,
797
799
  "stream": False,
798
800
  }
801
+ if self.config.temperature != 99:
802
+ payload["temperature"] = self.config.temperature
799
803
 
800
804
  # Ollama does not require an API key, but we add max_tokens if configured
801
805
  if self.config.max_tokens:
@@ -912,8 +916,9 @@ class LLMClient:
912
916
  {"role": "system", "content": system_prompt},
913
917
  {"role": "user", "content": user_message},
914
918
  ],
915
- "temperature": self.config.temperature,
916
919
  }
920
+ if self.config.temperature != 99:
921
+ payload["temperature"] = self.config.temperature
917
922
 
918
923
  # Add max_tokens if configured (some Copilot models
919
924
  # may not support this parameter)
@@ -1028,7 +1033,7 @@ class LLMClient:
1028
1033
  system_prompt: str,
1029
1034
  user_message: str,
1030
1035
  ) -> str:
1031
- """Calls Bedrock InvokeModel using an HTTP Bearer token (long-term API key).
1036
+ """Calls Bedrock Converse using an HTTP Bearer token (long-term API key).
1032
1037
 
1033
1038
  Args:
1034
1039
  region: AWS region, e.g. ``us-east-1``.
@@ -1051,14 +1056,19 @@ class LLMClient:
1051
1056
 
1052
1057
  # The model ARN contains ':' and '/' that must be URL-encoded in the path
1053
1058
  model_encoded = urllib.parse.quote(self.config.model, safe="")
1054
- url = f"https://bedrock-runtime.{region}.amazonaws.com/model/{model_encoded}/invoke"
1059
+ url = f"https://bedrock-runtime.{region}.amazonaws.com/model/{model_encoded}/converse"
1060
+
1061
+ inference_config = {"maxTokens": self.config.max_tokens}
1062
+ if self.config.temperature != 99:
1063
+ inference_config["temperature"] = self.config.temperature
1055
1064
 
1056
1065
  payload = json.dumps({
1057
- "anthropic_version": "bedrock-2023-05-31",
1058
- "max_tokens": self.config.max_tokens,
1059
- "temperature": self.config.temperature,
1060
- "system": system_prompt,
1061
- "messages": [{"role": "user", "content": user_message}],
1066
+ "system": [{"text": system_prompt}],
1067
+ "messages": [{
1068
+ "role": "user",
1069
+ "content": [{"text": user_message}],
1070
+ }],
1071
+ "inferenceConfig": inference_config,
1062
1072
  }, separators=(",", ":"))
1063
1073
 
1064
1074
  headers = {
@@ -1084,10 +1094,9 @@ class LLMClient:
1084
1094
  except ValueError as exc:
1085
1095
  raise LLMError(f"Invalid JSON from Bedrock: {resp.text[:500]}") from exc
1086
1096
 
1087
- content = data.get("content", [])
1097
+ content = data.get("output", {}).get("message", {}).get("content", [])
1088
1098
  text_parts = [
1089
- item["text"] for item in content
1090
- if item.get("type") == "text" and "text" in item
1099
+ item["text"] for item in content if "text" in item
1091
1100
  ]
1092
1101
  text = "\n".join(part for part in text_parts if part).strip()
1093
1102
  if not text:
@@ -1103,7 +1112,7 @@ class LLMClient:
1103
1112
  system_prompt: str,
1104
1113
  user_message: str,
1105
1114
  ) -> str:
1106
- """Calls Bedrock InvokeModel with manual AWS SigV4 HMAC-SHA256 signing.
1115
+ """Calls Bedrock Converse with manual AWS SigV4 HMAC-SHA256 signing.
1107
1116
 
1108
1117
  Equivalent to the C# BedrockLlmClient implementation.
1109
1118
 
@@ -1135,15 +1144,20 @@ class LLMClient:
1135
1144
 
1136
1145
  host = f"bedrock-runtime.{region}.amazonaws.com"
1137
1146
  model_encoded = urllib.parse.quote(self.config.model, safe="")
1138
- endpoint = f"https://{host}/model/{model_encoded}/invoke"
1147
+ endpoint = f"https://{host}/model/{model_encoded}/converse"
1139
1148
  service = "bedrock"
1140
1149
 
1150
+ inference_config = {"maxTokens": self.config.max_tokens}
1151
+ if self.config.temperature != 99:
1152
+ inference_config["temperature"] = self.config.temperature
1153
+
1141
1154
  payload = json.dumps({
1142
- "anthropic_version": "bedrock-2023-05-31",
1143
- "max_tokens": self.config.max_tokens,
1144
- "temperature": self.config.temperature,
1145
- "system": system_prompt,
1146
- "messages": [{"role": "user", "content": user_message}],
1155
+ "system": [{"text": system_prompt}],
1156
+ "messages": [{
1157
+ "role": "user",
1158
+ "content": [{"text": user_message}],
1159
+ }],
1160
+ "inferenceConfig": inference_config,
1147
1161
  }, separators=(",", ":"))
1148
1162
 
1149
1163
  now = datetime.datetime.now(datetime.timezone.utc)
@@ -1157,7 +1171,7 @@ class LLMClient:
1157
1171
  # Splitting by '/' and encoding each segment mirrors the C# SigV4 implementation.
1158
1172
  canonical_uri = "/".join(
1159
1173
  urllib.parse.quote(seg, safe="")
1160
- for seg in f"/model/{self.config.model}/invoke".split("/")
1174
+ for seg in f"/model/{self.config.model}/converse".split("/")
1161
1175
  )
1162
1176
 
1163
1177
  headers_to_sign = {
@@ -1227,10 +1241,9 @@ class LLMClient:
1227
1241
  except ValueError as exc:
1228
1242
  raise LLMError(f"Invalid JSON from Bedrock: {resp.text[:500]}") from exc
1229
1243
 
1230
- content = data.get("content", [])
1244
+ content = data.get("output", {}).get("message", {}).get("content", [])
1231
1245
  text_parts = [
1232
- item["text"] for item in content
1233
- if item.get("type") == "text" and "text" in item
1246
+ item["text"] for item in content if "text" in item
1234
1247
  ]
1235
1248
  text = "\n".join(part for part in text_parts if part).strip()
1236
1249
  if not text:
@@ -1277,6 +1290,10 @@ class LLMClient:
1277
1290
  session = boto3.Session(**session_kwargs)
1278
1291
  client = session.client("bedrock-runtime", region_name=region)
1279
1292
 
1293
+ inference_config = {"maxTokens": self.config.max_tokens}
1294
+ if self.config.temperature != 99:
1295
+ inference_config["temperature"] = self.config.temperature
1296
+
1280
1297
  response = client.converse(
1281
1298
  modelId=self.config.model,
1282
1299
  system=[{"text": system_prompt}],
@@ -1286,10 +1303,7 @@ class LLMClient:
1286
1303
  "content": [{"text": user_message}],
1287
1304
  }
1288
1305
  ],
1289
- inferenceConfig={
1290
- "temperature": self.config.temperature,
1291
- "maxTokens": self.config.max_tokens,
1292
- },
1306
+ inferenceConfig=inference_config,
1293
1307
  )
1294
1308
 
1295
1309
  content = (
@@ -26,7 +26,7 @@ llm:
26
26
  # Maximum tokens in response
27
27
  max_tokens: 4096
28
28
 
29
- # Temperature (0.0 = deterministic, 1.0 = creative)
29
+ # Temperature (0.0 = deterministic, 1.0 = creative, 99 omits it for all models that dont support this)
30
30
  temperature: 0.0
31
31
 
32
32
  # ---------------------------------------------------------------------------
@@ -18,7 +18,9 @@ Works with both on-premises TFS and Azure DevOps Services.
18
18
 
19
19
  import base64
20
20
  import difflib
21
+ import hashlib
21
22
  import os
23
+ import re
22
24
  from typing import Optional
23
25
 
24
26
  from .config import ReviewConfig
@@ -35,6 +37,12 @@ class TFSClient:
35
37
 
36
38
  API_VERSION = "7.0"
37
39
 
40
+ # Hidden fingerprint marker embedded in AI-generated comments to detect
41
+ # and skip duplicates on pipeline re-runs. Rendered as an HTML comment,
42
+ # which Azure DevOps does not display in the PR UI.
43
+ MARKER_PREFIX = "ai-review:"
44
+ _MARKER_RE = re.compile(r"<!--\s*ai-review:([0-9a-zA-Z_]+)\s*-->")
45
+
38
46
  def __init__(self, config: ReviewConfig):
39
47
  self.config = config
40
48
  self.base_url = config.tfs_base_url.rstrip("/")
@@ -617,20 +625,36 @@ class TFSClient:
617
625
  # Pull Requests - Comments
618
626
  # ==================================================================
619
627
  def post_general_comment(self, repository: str, pr_id: int,
620
- comment: str, status: str = "active") -> dict:
628
+ comment: str, status: str = "active",
629
+ dedup_marker: Optional[str] = None) -> dict:
621
630
  """
622
631
  Posts a general comment on a Pull Request (not associated with a file).
623
-
632
+
624
633
  Args:
625
634
  repository: Repository name.
626
635
  pr_id: Pull Request ID.
627
636
  comment: Comment text (supports Markdown).
628
637
  status: Thread status - "active", "fixed", "wontFix",
629
638
  "closed", "pending", "byDesign"
630
-
639
+ dedup_marker: Optional stable identifier. When provided, a hidden
640
+ fingerprint is appended to the comment and existing PR
641
+ threads are checked first; if the same marker already
642
+ exists, the comment is skipped instead of duplicated.
643
+
631
644
  Returns:
632
- Created thread data.
645
+ Created thread data, or a ``{"skipped": True, ...}`` dict when the
646
+ comment was skipped due to an already existing marker.
633
647
  """
648
+ marker: Optional[str] = None
649
+ if dedup_marker is not None:
650
+ marker = self._general_marker(dedup_marker)
651
+ existing = self._extract_existing_markers(
652
+ self.list_pr_threads(repository, pr_id)
653
+ )
654
+ if marker in existing:
655
+ return {"skipped": True, "marker": marker}
656
+ comment = f"{comment}\n\n{self._marker_html(marker)}"
657
+
634
658
  path = f"git/repositories/{repository}/pullrequests/{pr_id}/threads"
635
659
  data = {
636
660
  "comments": [
@@ -729,13 +753,29 @@ class TFSClient:
729
753
  comment_mode = (comment_mode or "structured").lower()
730
754
  use_inline_comments = comment_mode == "structured"
731
755
 
732
- for c in comments:
733
- # Build formatted comment text
734
- text = self._format_review_comment(c)
756
+ # Load existing markers once to skip comments already posted in a
757
+ # previous run (deduplication is always active).
758
+ existing_markers = self._extract_existing_markers(
759
+ self.list_pr_threads(repository, pr_id)
760
+ )
735
761
 
762
+ for c in comments:
736
763
  file_path = c.get("file", "")
737
764
  line = c.get("line", 0)
738
765
 
766
+ marker = self._comment_marker(c)
767
+ if marker in existing_markers:
768
+ results.append({
769
+ "skipped": True,
770
+ "file": file_path,
771
+ "line": line,
772
+ "error": "duplicate (already posted)",
773
+ })
774
+ continue
775
+
776
+ # Build formatted comment text with the hidden fingerprint appended.
777
+ text = f"{self._format_review_comment(c)}\n\n{self._marker_html(marker)}"
778
+
739
779
  try:
740
780
  if use_inline_comments and file_path and line > 0:
741
781
  # Inline comment
@@ -747,6 +787,8 @@ class TFSClient:
747
787
  result = self.post_general_comment(
748
788
  repository, pr_id, text
749
789
  )
790
+ # Record the marker to avoid duplicates within the same batch.
791
+ existing_markers.add(marker)
750
792
  results.append({
751
793
  "success": True,
752
794
  "file": file_path,
@@ -793,6 +835,86 @@ class TFSClient:
793
835
 
794
836
  return "\n".join(parts)
795
837
 
838
+ # ==================================================================
839
+ # Deduplication - hidden fingerprint markers
840
+ # ==================================================================
841
+ def list_pr_threads(self, repository: str, pr_id: int) -> list[dict]:
842
+ """
843
+ Lists all comment threads of a Pull Request.
844
+
845
+ Args:
846
+ repository: Repository name.
847
+ pr_id: Pull Request ID.
848
+
849
+ Returns:
850
+ List of thread dictionaries (empty list when none exist).
851
+ """
852
+ path = f"git/repositories/{repository}/pullrequests/{pr_id}/threads"
853
+ response = self._get(path)
854
+ return response.get("value", [])
855
+
856
+ def _comment_marker(self, comment: dict) -> str:
857
+ """
858
+ Builds a stable fingerprint for a structured review comment.
859
+
860
+ The hash is derived from the file, line and type so the same finding
861
+ location produces the same marker across runs, regardless of small
862
+ wording changes in the comment text.
863
+
864
+ Args:
865
+ comment: Structured comment with ``file``, ``line`` and ``type`` keys.
866
+
867
+ Returns:
868
+ Hexadecimal SHA-256 digest string.
869
+ """
870
+ payload = "|".join([
871
+ str(comment.get("file", "")),
872
+ str(comment.get("line", "")),
873
+ str(comment.get("type", "")),
874
+ ])
875
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
876
+
877
+ def _general_marker(self, identifier: str) -> str:
878
+ """
879
+ Builds a stable marker for a general comment from a fixed identifier.
880
+
881
+ Args:
882
+ identifier: Stable identifier (e.g. ``"summary"``).
883
+
884
+ Returns:
885
+ Sanitized marker string usable inside the HTML comment.
886
+ """
887
+ return re.sub(r"[^0-9a-zA-Z_]", "_", identifier)
888
+
889
+ def _marker_html(self, marker: str) -> str:
890
+ """
891
+ Wraps a marker into a hidden HTML comment.
892
+
893
+ Args:
894
+ marker: Fingerprint value.
895
+
896
+ Returns:
897
+ HTML comment string invisible in the rendered PR.
898
+ """
899
+ return f"<!-- {self.MARKER_PREFIX}{marker} -->"
900
+
901
+ def _extract_existing_markers(self, threads: list[dict]) -> set[str]:
902
+ """
903
+ Extracts all fingerprint markers present in existing PR threads.
904
+
905
+ Args:
906
+ threads: Threads as returned by :meth:`list_pr_threads`.
907
+
908
+ Returns:
909
+ Set of marker values found across every comment.
910
+ """
911
+ markers: set[str] = set()
912
+ for thread in threads:
913
+ for comment in thread.get("comments", []) or []:
914
+ content = comment.get("content", "") or ""
915
+ markers.update(self._MARKER_RE.findall(content))
916
+ return markers
917
+
796
918
  def _status_to_int(self, status: str) -> int:
797
919
  """Converts status string to API integer."""
798
920
  status_map = {
@@ -201,7 +201,8 @@ def test_format_post_results_summarizes_statuses() -> None:
201
201
  assert "Skipped at c.py" in rendered
202
202
  assert "Failed at b.py" in rendered
203
203
  assert "1 posted" in rendered
204
- assert "2 failed" in rendered
204
+ assert "1 skipped" in rendered
205
+ assert "1 failed" in rendered
205
206
 
206
207
 
207
208
  def test_spinner_frame_rotates_frames() -> None:
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import json
8
9
  import re
9
10
  import sys
10
11
  from pathlib import Path
@@ -606,7 +607,10 @@ def test_bedrock_boto3_success_and_error_paths(monkeypatch: pytest.MonkeyPatch)
606
607
  class FakeBedrockClient:
607
608
  """Stub Bedrock runtime client."""
608
609
 
610
+ last_converse_kwargs: dict[str, object] = {}
611
+
609
612
  def converse(self, **kwargs: object) -> dict:
613
+ FakeBedrockClient.last_converse_kwargs = kwargs
610
614
  return {"output": {"message": {"content": [{"text": "bedrock ok"}]}}}
611
615
 
612
616
  class FakeSession:
@@ -638,6 +642,12 @@ def test_bedrock_boto3_success_and_error_paths(monkeypatch: pytest.MonkeyPatch)
638
642
  assert client._call_bedrock("sys", "user") == "bedrock ok"
639
643
  assert client._call_bedrock_boto3("us-east-1", "sys", "user") == "bedrock ok"
640
644
 
645
+ client.config.temperature = 99
646
+ assert client._call_bedrock_boto3("us-east-1", "sys", "user") == "bedrock ok"
647
+ assert FakeBedrockClient.last_converse_kwargs["inferenceConfig"] == {
648
+ "maxTokens": 256,
649
+ }
650
+
641
651
  class BrokenSession(FakeSession):
642
652
  """Session that raises provider-side failures."""
643
653
 
@@ -689,8 +699,8 @@ def test_call_bedrock_routing_dispatches_correct_method(mocker) -> None:
689
699
 
690
700
 
691
701
  def test_call_bedrock_bearer_success_and_errors(monkeypatch: pytest.MonkeyPatch) -> None:
692
- """_call_bedrock_bearer should use Bearer auth, URL-encode the model ARN and parse content."""
693
- model_arn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-sonnet"
702
+ """_call_bedrock_bearer should use the provider-agnostic Converse API."""
703
+ model_arn = "arn:aws:bedrock:us-east-1::foundation-model/openai.gpt-oss-120b-1:0"
694
704
  client = LLMClient(
695
705
  make_llm_config(
696
706
  llm_provider="bedrock",
@@ -703,15 +713,31 @@ def test_call_bedrock_bearer_success_and_errors(monkeypatch: pytest.MonkeyPatch)
703
713
  )
704
714
 
705
715
  # Success path: response parsed correctly, model ARN URL-encoded in URL
706
- success_resp = FakeResponse(json_data={"content": [{"type": "text", "text": "bearer ok"}]})
716
+ success_resp = FakeResponse(
717
+ json_data={"output": {"message": {"content": [{"text": "bearer ok"}]}}}
718
+ )
707
719
  req_module = install_requests_raw(monkeypatch, success_resp)
708
720
  result = client._call_bedrock_bearer("us-east-1", "my-bearer-key", "sys", "user")
709
721
  assert result == "bearer ok"
710
722
  call = req_module._calls[0]
711
723
  assert "arn%3Aaws%3Abedrock" in call["url"] # ':' encoded as %3A
712
- assert "anthropic.claude-3-sonnet" in call["url"]
724
+ assert "openai.gpt-oss-120b-1%3A0" in call["url"]
725
+ assert call["url"].endswith("/converse")
713
726
  assert call["headers"]["Authorization"] == "Bearer my-bearer-key"
714
727
  assert call["headers"]["Content-Type"] == "application/json"
728
+ payload = json.loads(call["data"])
729
+ assert "anthropic_version" not in payload
730
+ assert payload == {
731
+ "system": [{"text": "sys"}],
732
+ "messages": [{"role": "user", "content": [{"text": "user"}]}],
733
+ "inferenceConfig": {"temperature": 0.2, "maxTokens": 256},
734
+ }
735
+
736
+ client.config.temperature = 99
737
+ req_module = install_requests_raw(monkeypatch, success_resp)
738
+ assert client._call_bedrock_bearer("us-east-1", "my-bearer-key", "sys", "user") == "bearer ok"
739
+ sentinel_payload = json.loads(req_module._calls[0]["data"])
740
+ assert sentinel_payload["inferenceConfig"] == {"maxTokens": 256}
715
741
 
716
742
  # 401 → specific authentication error message
717
743
  install_requests_raw(monkeypatch, FakeResponse(status_code=401))
@@ -724,12 +750,12 @@ def test_call_bedrock_bearer_success_and_errors(monkeypatch: pytest.MonkeyPatch)
724
750
  client._call_bedrock_bearer("us-east-1", "my-bearer-key", "sys", "user")
725
751
 
726
752
  # Empty content list → Unexpected response error
727
- install_requests_raw(monkeypatch, FakeResponse(json_data={"content": []}))
753
+ install_requests_raw(monkeypatch, FakeResponse(json_data={"output": {"message": {"content": []}}}))
728
754
  with pytest.raises(LLMError, match="Unexpected Bedrock response"):
729
755
  client._call_bedrock_bearer("us-east-1", "my-bearer-key", "sys", "user")
730
756
 
731
757
  # Content items with no text type → Unexpected response error
732
- install_requests_raw(monkeypatch, FakeResponse(json_data={"content": [{"type": "image", "data": "..."}]}))
758
+ install_requests_raw(monkeypatch, FakeResponse(json_data={"output": {"message": {"content": [{"image": {}}]}}}))
733
759
  with pytest.raises(LLMError, match="Unexpected Bedrock response"):
734
760
  client._call_bedrock_bearer("us-east-1", "my-bearer-key", "sys", "user")
735
761
 
@@ -741,18 +767,20 @@ def test_call_bedrock_bearer_success_and_errors(monkeypatch: pytest.MonkeyPatch)
741
767
 
742
768
 
743
769
  def test_call_bedrock_sigv4_success_and_errors(monkeypatch: pytest.MonkeyPatch) -> None:
744
- """_call_bedrock_sigv4 should add SigV4 headers and parse content correctly."""
770
+ """_call_bedrock_sigv4 should use Converse with correctly signed requests."""
745
771
  client = LLMClient(
746
772
  make_llm_config(
747
773
  llm_provider="bedrock",
748
774
  api_key="",
749
- model="anthropic.claude-3-sonnet",
775
+ model="openai.gpt-oss-120b-1:0",
750
776
  bedrock_region="us-east-1",
751
777
  bedrock_access_key_id="AKID",
752
778
  bedrock_secret_access_key="secret",
753
779
  )
754
780
  )
755
- success_resp = FakeResponse(json_data={"content": [{"type": "text", "text": "sigv4 ok"}]})
781
+ success_resp = FakeResponse(
782
+ json_data={"output": {"message": {"content": [{"text": "sigv4 ok"}]}}}
783
+ )
756
784
 
757
785
  # Success path: SigV4 headers present in request
758
786
  req_module = install_requests_raw(monkeypatch, success_resp)
@@ -763,6 +791,16 @@ def test_call_bedrock_sigv4_success_and_errors(monkeypatch: pytest.MonkeyPatch)
763
791
  assert "x-amz-date" in call["headers"]
764
792
  assert "x-amz-content-sha256" in call["headers"]
765
793
  assert "x-amz-security-token" not in call["headers"]
794
+ assert call["url"].endswith("/converse")
795
+ payload = json.loads(call["data"])
796
+ assert "anthropic_version" not in payload
797
+ assert payload["inferenceConfig"] == {"temperature": 0.2, "maxTokens": 256}
798
+
799
+ client.config.temperature = 99
800
+ req_module = install_requests_raw(monkeypatch, success_resp)
801
+ assert client._call_bedrock_sigv4("us-east-1", "AKID", "secret", "", "sys", "user") == "sigv4 ok"
802
+ sentinel_payload = json.loads(req_module._calls[0]["data"])
803
+ assert sentinel_payload["inferenceConfig"] == {"maxTokens": 256}
766
804
 
767
805
  # Session token is forwarded as x-amz-security-token
768
806
  req_module = install_requests_raw(monkeypatch, success_resp)
@@ -782,7 +820,7 @@ def test_call_bedrock_sigv4_success_and_errors(monkeypatch: pytest.MonkeyPatch)
782
820
  client._call_bedrock_sigv4("us-east-1", "AKID", "secret", "", "sys", "user")
783
821
 
784
822
  # Empty content → Unexpected response error
785
- install_requests_raw(monkeypatch, FakeResponse(json_data={"content": []}))
823
+ install_requests_raw(monkeypatch, FakeResponse(json_data={"output": {"message": {"content": []}}}))
786
824
  with pytest.raises(LLMError, match="Unexpected Bedrock response"):
787
825
  client._call_bedrock_sigv4("us-east-1", "AKID", "secret", "", "sys", "user")
788
826
 
@@ -693,6 +693,7 @@ def test_post_inline_comment_requires_iterations(mocker) -> None:
693
693
  def test_post_review_comments_formats_and_routes_comments(mocker) -> None:
694
694
  """It should route inline and general comments and collect failures."""
695
695
  client = TFSClient(make_tfs_config())
696
+ mocker.patch("src.tfs_client.TFSClient.list_pr_threads", return_value=[])
696
697
  formatter = mocker.patch("src.tfs_client.TFSClient._format_review_comment", side_effect=lambda comment: f"TEXT:{comment['comment']}")
697
698
  inline = mocker.patch("src.tfs_client.TFSClient.post_inline_comment", return_value={"id": 1})
698
699
  general = mocker.patch("src.tfs_client.TFSClient.post_general_comment", side_effect=[{"id": 2}, TFSError("boom")])
@@ -715,6 +716,116 @@ def test_post_review_comments_formats_and_routes_comments(mocker) -> None:
715
716
  assert results[2]["success"] is False
716
717
 
717
718
 
719
+ def test_list_pr_threads_returns_value_list(mocker) -> None:
720
+ """It should GET the PR threads endpoint and return the value list."""
721
+ client = TFSClient(make_tfs_config())
722
+ get_mock = mocker.patch(
723
+ "src.tfs_client.TFSClient._get",
724
+ return_value={"value": [{"id": 1}, {"id": 2}]},
725
+ )
726
+
727
+ threads = client.list_pr_threads("repo-a", 7)
728
+
729
+ assert threads == [{"id": 1}, {"id": 2}]
730
+ path = get_mock.call_args.args[0]
731
+ assert path == "git/repositories/repo-a/pullrequests/7/threads"
732
+
733
+
734
+ def test_comment_marker_is_stable_and_content_sensitive() -> None:
735
+ """It should produce a stable marker per location, ignoring comment text."""
736
+ client = TFSClient(make_tfs_config())
737
+ base = {"file": "src/app.py", "line": 3, "type": "bug", "comment": "x"}
738
+
739
+ assert client._comment_marker(base) == client._comment_marker(dict(base))
740
+ # Comment text is not part of the fingerprint.
741
+ assert client._comment_marker(base) == client._comment_marker(
742
+ {**base, "comment": "y"}
743
+ )
744
+ assert client._comment_marker(base) != client._comment_marker(
745
+ {**base, "line": 4}
746
+ )
747
+ assert client._comment_marker(base) != client._comment_marker(
748
+ {**base, "type": "security"}
749
+ )
750
+
751
+
752
+ def test_extract_existing_markers_reads_all_threads() -> None:
753
+ """It should extract every fingerprint present in thread comments."""
754
+ client = TFSClient(make_tfs_config())
755
+ threads = [
756
+ {"comments": [{"content": "hi <!-- ai-review:abc123 -->"}]},
757
+ {"comments": [
758
+ {"content": "no marker here"},
759
+ {"content": "<!-- ai-review:summary -->"},
760
+ ]},
761
+ ]
762
+
763
+ assert client._extract_existing_markers(threads) == {"abc123", "summary"}
764
+
765
+
766
+ def test_post_review_comments_skips_existing_and_dedupes_batch(mocker) -> None:
767
+ """It should skip already posted markers and dedupe within one batch."""
768
+ client = TFSClient(make_tfs_config())
769
+ duplicate = {"file": "src/app.py", "line": 3, "type": "bug", "comment": "dup"}
770
+ existing_marker = client._comment_marker(duplicate)
771
+ mocker.patch(
772
+ "src.tfs_client.TFSClient.list_pr_threads",
773
+ return_value=[{"comments": [
774
+ {"content": client._marker_html(existing_marker)}
775
+ ]}],
776
+ )
777
+ inline = mocker.patch(
778
+ "src.tfs_client.TFSClient.post_inline_comment", return_value={"id": 9}
779
+ )
780
+
781
+ new_comment = {"file": "src/app.py", "line": 5, "type": "bug", "comment": "new"}
782
+ results = client.post_review_comments(
783
+ "repo-a",
784
+ 1,
785
+ [duplicate, new_comment, dict(new_comment)],
786
+ )
787
+
788
+ # First is skipped (already exists), second posted, third skipped (batch dedup).
789
+ assert results[0]["skipped"] is True
790
+ assert results[1]["success"] is True
791
+ assert results[2]["skipped"] is True
792
+ inline.assert_called_once()
793
+ posted_text = inline.call_args.args[4]
794
+ assert client._marker_html(client._comment_marker(new_comment)) in posted_text
795
+
796
+
797
+ def test_post_general_comment_dedup_marker_skips_when_present(mocker) -> None:
798
+ """It should skip general comments whose dedup marker already exists."""
799
+ client = TFSClient(make_tfs_config())
800
+ post_mock = mocker.patch("src.tfs_client.TFSClient._post", return_value={"id": 5})
801
+ mocker.patch(
802
+ "src.tfs_client.TFSClient.list_pr_threads",
803
+ return_value=[{"comments": [{"content": "<!-- ai-review:summary -->"}]}],
804
+ )
805
+
806
+ result = client.post_general_comment(
807
+ "repo-a", 1, "summary body", dedup_marker="summary"
808
+ )
809
+
810
+ assert result["skipped"] is True
811
+ post_mock.assert_not_called()
812
+
813
+
814
+ def test_post_general_comment_dedup_marker_posts_when_absent(mocker) -> None:
815
+ """It should append the marker and post when it is not already present."""
816
+ client = TFSClient(make_tfs_config())
817
+ post_mock = mocker.patch("src.tfs_client.TFSClient._post", return_value={"id": 5})
818
+ mocker.patch("src.tfs_client.TFSClient.list_pr_threads", return_value=[])
819
+
820
+ result = client.post_general_comment(
821
+ "repo-a", 1, "summary body", dedup_marker="summary"
822
+ )
823
+
824
+ assert result == {"id": 5}
825
+ _, payload = post_mock.call_args.args[:2]
826
+ assert "<!-- ai-review:summary -->" in payload["comments"][0]["content"]
827
+
828
+
718
829
  def test_repository_helpers_and_status_formatting(mocker) -> None:
719
830
  """It should format review comments and resolve repositories by name."""
720
831
  client = TFSClient(make_tfs_config())