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.
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/PKG-INFO +4 -9
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/README.md +3 -8
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/PKG-INFO +4 -9
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/pyproject.toml +1 -1
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/ai_review.py +9 -2
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/formatter.py +3 -1
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/llm_client.py +44 -30
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/prompts/config.yaml.template +1 -1
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/tfs_client.py +129 -7
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_formatter.py +2 -1
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_llm_client.py +48 -10
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_tfs_client.py +111 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/requires.txt +0 -0
- {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
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/setup.cfg +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/__init__.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/config.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/context_extractor.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/git_utils.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/prompt_utils.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/prompts/review_prompt.md.template +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/src/token_estimator.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_ai_review.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_config.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_context_extractor.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_git_utils.py +0 -0
- {code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/tests/test_prompt_utils.py +0 -0
- {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
|
+
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
|
|
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
|

|
|
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
|
-
##
|
|
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
|
|
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
|

|
|
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
|
-
##
|
|
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
|
+
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
|
|
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
|

|
|
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
|
-
##
|
|
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.
|
|
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(
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
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}/
|
|
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
|
-
"
|
|
1058
|
-
"
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
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}/
|
|
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
|
-
"
|
|
1143
|
-
"
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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}/
|
|
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"
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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 "
|
|
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
|
|
693
|
-
model_arn = "arn:aws:bedrock:us-east-1::foundation-model/
|
|
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(
|
|
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 "
|
|
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={"
|
|
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
|
|
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="
|
|
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(
|
|
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())
|
{code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/code_review_ai_cli.egg-info/requires.txt
RENAMED
|
File without changes
|
{code_review_ai_cli-2.2.3 → code_review_ai_cli-2.2.5}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|