code-review-ai-cli 2.2.2__tar.gz → 2.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/PKG-INFO +11 -1
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/README.md +6 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/PKG-INFO +11 -1
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/SOURCES.txt +3 -1
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/requires.txt +4 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/pyproject.toml +5 -1
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/__init__.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/ai_review.py +22 -2
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/config.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/context_extractor.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/formatter.py +6 -1
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/git_utils.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/llm_client.py +18 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/prompt_utils.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/tfs_client.py +132 -7
- code_review_ai_cli-2.2.4/src/token_estimator.py +98 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_ai_review.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_config.py +389 -386
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_context_extractor.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_formatter.py +5 -1
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_git_utils.py +159 -156
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_llm_client.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_prompt_utils.py +3 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_tfs_client.py +114 -0
- code_review_ai_cli-2.2.4/tests/test_token_estimator.py +102 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/dependency_links.txt +0 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/entry_points.txt +0 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/top_level.txt +0 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/setup.cfg +0 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/prompts/config.yaml.template +0 -0
- {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/prompts/review_prompt.md.template +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.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
|
|
@@ -17,6 +17,8 @@ Requires-Python: >=3.10
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
Requires-Dist: requests>=2.28.0
|
|
19
19
|
Requires-Dist: PyYAML>=6.0
|
|
20
|
+
Requires-Dist: litellm>=1.40.0
|
|
21
|
+
Requires-Dist: tiktoken>=0.7.0
|
|
20
22
|
Provides-Extra: bedrock
|
|
21
23
|
Requires-Dist: boto3>=1.34.0; extra == "bedrock"
|
|
22
24
|
Provides-Extra: openai
|
|
@@ -29,6 +31,8 @@ Provides-Extra: tree-sitter
|
|
|
29
31
|
Requires-Dist: tree-sitter-languages>=1.10.2; extra == "tree-sitter"
|
|
30
32
|
Provides-Extra: all
|
|
31
33
|
Requires-Dist: boto3>=1.34.0; extra == "all"
|
|
34
|
+
Requires-Dist: litellm>=1.40.0; extra == "all"
|
|
35
|
+
Requires-Dist: tiktoken>=0.7.0; extra == "all"
|
|
32
36
|
Requires-Dist: openai>=1.0.0; extra == "all"
|
|
33
37
|
Requires-Dist: google-generativeai>=0.3.0; extra == "all"
|
|
34
38
|
Requires-Dist: anthropic>=0.18.0; extra == "all"
|
|
@@ -51,6 +55,8 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
|
|
|
51
55
|
- Configuration exclusively via `config.yaml`
|
|
52
56
|
- Providers LLM: OpenAI, Azure OpenAI, Gemini, Claude, Ollama, GitHub Copilot, AWS Bedrock
|
|
53
57
|
- Diff filtering by **excluded path prefixes** (`excluded_paths`) and/or **file extensions** (`file_extensions_filter`)
|
|
58
|
+
- Prompt token consumption estimation with LiteLLM (`Total Tokens`)
|
|
59
|
+
- Validation to ensure comments are not duplicated in re-runs (CI/CD Pipelines)
|
|
54
60
|
|
|
55
61
|
## Documentation
|
|
56
62
|
|
|
@@ -63,6 +69,7 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
|
|
|
63
69
|
|
|
64
70
|

|
|
65
71
|
|
|
72
|
+
|
|
66
73
|
## Installation
|
|
67
74
|
|
|
68
75
|
Install from PyPI:
|
|
@@ -144,3 +151,6 @@ output:
|
|
|
144
151
|
- `AI Review: PR (Dry-Run)`
|
|
145
152
|
- `AI Review: List Active PRs`
|
|
146
153
|
- `AI Review: Interactive Mode`
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
This project is licensed under the Apache Licence 2.0 — see the [LICENSE](/License).
|
|
@@ -11,6 +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 consumption estimation with LiteLLM (`Total Tokens`)
|
|
15
|
+
- Validation to ensure comments are not duplicated in re-runs (CI/CD Pipelines)
|
|
14
16
|
|
|
15
17
|
## Documentation
|
|
16
18
|
|
|
@@ -23,6 +25,7 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
|
|
|
23
25
|
|
|
24
26
|

|
|
25
27
|
|
|
28
|
+
|
|
26
29
|
## Installation
|
|
27
30
|
|
|
28
31
|
Install from PyPI:
|
|
@@ -104,3 +107,6 @@ output:
|
|
|
104
107
|
- `AI Review: PR (Dry-Run)`
|
|
105
108
|
- `AI Review: List Active PRs`
|
|
106
109
|
- `AI Review: Interactive Mode`
|
|
110
|
+
|
|
111
|
+
## License
|
|
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.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
|
|
@@ -17,6 +17,8 @@ Requires-Python: >=3.10
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
Requires-Dist: requests>=2.28.0
|
|
19
19
|
Requires-Dist: PyYAML>=6.0
|
|
20
|
+
Requires-Dist: litellm>=1.40.0
|
|
21
|
+
Requires-Dist: tiktoken>=0.7.0
|
|
20
22
|
Provides-Extra: bedrock
|
|
21
23
|
Requires-Dist: boto3>=1.34.0; extra == "bedrock"
|
|
22
24
|
Provides-Extra: openai
|
|
@@ -29,6 +31,8 @@ Provides-Extra: tree-sitter
|
|
|
29
31
|
Requires-Dist: tree-sitter-languages>=1.10.2; extra == "tree-sitter"
|
|
30
32
|
Provides-Extra: all
|
|
31
33
|
Requires-Dist: boto3>=1.34.0; extra == "all"
|
|
34
|
+
Requires-Dist: litellm>=1.40.0; extra == "all"
|
|
35
|
+
Requires-Dist: tiktoken>=0.7.0; extra == "all"
|
|
32
36
|
Requires-Dist: openai>=1.0.0; extra == "all"
|
|
33
37
|
Requires-Dist: google-generativeai>=0.3.0; extra == "all"
|
|
34
38
|
Requires-Dist: anthropic>=0.18.0; extra == "all"
|
|
@@ -51,6 +55,8 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
|
|
|
51
55
|
- Configuration exclusively via `config.yaml`
|
|
52
56
|
- Providers LLM: OpenAI, Azure OpenAI, Gemini, Claude, Ollama, GitHub Copilot, AWS Bedrock
|
|
53
57
|
- Diff filtering by **excluded path prefixes** (`excluded_paths`) and/or **file extensions** (`file_extensions_filter`)
|
|
58
|
+
- Prompt token consumption estimation with LiteLLM (`Total Tokens`)
|
|
59
|
+
- Validation to ensure comments are not duplicated in re-runs (CI/CD Pipelines)
|
|
54
60
|
|
|
55
61
|
## Documentation
|
|
56
62
|
|
|
@@ -63,6 +69,7 @@ AI Code Review is an automated AI-powered code review CLI, compatible with vario
|
|
|
63
69
|
|
|
64
70
|

|
|
65
71
|
|
|
72
|
+
|
|
66
73
|
## Installation
|
|
67
74
|
|
|
68
75
|
Install from PyPI:
|
|
@@ -144,3 +151,6 @@ output:
|
|
|
144
151
|
- `AI Review: PR (Dry-Run)`
|
|
145
152
|
- `AI Review: List Active PRs`
|
|
146
153
|
- `AI Review: Interactive Mode`
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
This project is licensed under the Apache Licence 2.0 — see the [LICENSE](/License).
|
{code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/SOURCES.txt
RENAMED
|
@@ -15,6 +15,7 @@ src/git_utils.py
|
|
|
15
15
|
src/llm_client.py
|
|
16
16
|
src/prompt_utils.py
|
|
17
17
|
src/tfs_client.py
|
|
18
|
+
src/token_estimator.py
|
|
18
19
|
src/prompts/config.yaml.template
|
|
19
20
|
src/prompts/review_prompt.md.template
|
|
20
21
|
tests/test_ai_review.py
|
|
@@ -24,4 +25,5 @@ tests/test_formatter.py
|
|
|
24
25
|
tests/test_git_utils.py
|
|
25
26
|
tests/test_llm_client.py
|
|
26
27
|
tests/test_prompt_utils.py
|
|
27
|
-
tests/test_tfs_client.py
|
|
28
|
+
tests/test_tfs_client.py
|
|
29
|
+
tests/test_token_estimator.py
|
|
@@ -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.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"
|
|
@@ -24,6 +24,8 @@ classifiers = [
|
|
|
24
24
|
dependencies = [
|
|
25
25
|
"requests>=2.28.0",
|
|
26
26
|
"PyYAML>=6.0",
|
|
27
|
+
"litellm>=1.40.0",
|
|
28
|
+
"tiktoken>=0.7.0",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
[project.optional-dependencies]
|
|
@@ -34,6 +36,8 @@ claude = ["anthropic>=0.18.0"]
|
|
|
34
36
|
tree-sitter = ["tree-sitter-languages>=1.10.2"]
|
|
35
37
|
all = [
|
|
36
38
|
"boto3>=1.34.0",
|
|
39
|
+
"litellm>=1.40.0",
|
|
40
|
+
"tiktoken>=0.7.0",
|
|
37
41
|
"openai>=1.0.0",
|
|
38
42
|
"google-generativeai>=0.3.0",
|
|
39
43
|
"anthropic>=0.18.0",
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
# License: Apache-2.0
|
|
3
|
+
# Copyright 2026 Sérgio Sereno
|
|
4
|
+
|
|
2
5
|
"""
|
|
3
6
|
AI Code Review - Main Script
|
|
4
7
|
==============================
|
|
@@ -516,6 +519,16 @@ def run_pr_review_workflow(args: argparse.Namespace, config: ReviewConfig,
|
|
|
516
519
|
elapsed = time.time() - start_time
|
|
517
520
|
progress.stop(formatter.format_success(f"Review completed in {elapsed:.1f}s"))
|
|
518
521
|
|
|
522
|
+
token_estimate = getattr(llm, "last_token_estimate", None)
|
|
523
|
+
if token_estimate is not None and token_estimate.total_prompt_tokens is not None:
|
|
524
|
+
print(formatter.format_info(
|
|
525
|
+
f"Total Prompt: {token_estimate.total_prompt_tokens} tokens"
|
|
526
|
+
))
|
|
527
|
+
else:
|
|
528
|
+
print(formatter.format_warning(
|
|
529
|
+
"Total Prompt: unavailable (LiteLLM and local tiktoken could not count the model)"
|
|
530
|
+
))
|
|
531
|
+
|
|
519
532
|
# --- Show structured comments preview ---
|
|
520
533
|
print(formatter.format_structured_comments(
|
|
521
534
|
structured_comments,
|
|
@@ -582,8 +595,15 @@ def run_pr_review_workflow(args: argparse.Namespace, config: ReviewConfig,
|
|
|
582
595
|
f"---\n"
|
|
583
596
|
f"*Automatic review generated by AI Code Review v{VERSION}*"
|
|
584
597
|
)
|
|
585
|
-
tfs.post_general_comment(
|
|
586
|
-
|
|
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."))
|
|
587
607
|
except TFSError as exc:
|
|
588
608
|
print(formatter.format_warning(f"Could not post general summary: {exc}"))
|
|
589
609
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# License: Apache-2.0
|
|
2
|
+
# Copyright 2026 Sérgio Sereno
|
|
3
|
+
|
|
1
4
|
"""
|
|
2
5
|
Formatting Module - AI Code Review
|
|
3
6
|
====================================
|
|
@@ -294,7 +297,8 @@ class ReviewFormatter:
|
|
|
294
297
|
lines = [f"\n{c.BOLD}📤 Comment posting results:{c.RESET}\n"]
|
|
295
298
|
|
|
296
299
|
success_count = sum(1 for r in results if r.get("success"))
|
|
297
|
-
|
|
300
|
+
skipped_count = sum(1 for r in results if r.get("skipped"))
|
|
301
|
+
fail_count = len(results) - success_count - skipped_count
|
|
298
302
|
|
|
299
303
|
for r in results:
|
|
300
304
|
if r.get("success"):
|
|
@@ -319,6 +323,7 @@ class ReviewFormatter:
|
|
|
319
323
|
lines.append(
|
|
320
324
|
f"\n {c.BOLD}Total: "
|
|
321
325
|
f"{c.GREEN}{success_count} posted{c.RESET}, "
|
|
326
|
+
f"{c.YELLOW}{skipped_count} skipped{c.RESET}, "
|
|
322
327
|
f"{c.RED}{fail_count} failed{c.RESET}"
|
|
323
328
|
)
|
|
324
329
|
return "\n".join(lines)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# License: Apache-2.0
|
|
2
|
+
# Copyright 2026 Sérgio Sereno
|
|
3
|
+
|
|
1
4
|
"""
|
|
2
5
|
LLM Client Module - AI Code Review
|
|
3
6
|
=====================================
|
|
@@ -18,6 +21,7 @@ import os
|
|
|
18
21
|
|
|
19
22
|
from .config import ReviewConfig
|
|
20
23
|
from .prompt_utils import detect_langs, filter_prompt_by_langs
|
|
24
|
+
from .token_estimator import TokenEstimate, TokenEstimator
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
class LLMError(Exception):
|
|
@@ -317,6 +321,11 @@ class LLMClient:
|
|
|
317
321
|
|
|
318
322
|
def __init__(self, config: ReviewConfig):
|
|
319
323
|
self.config = config
|
|
324
|
+
self.last_token_estimate = TokenEstimate(
|
|
325
|
+
total_prompt_tokens=None,
|
|
326
|
+
model=config.get_effective_model(),
|
|
327
|
+
error="No review has been performed yet.",
|
|
328
|
+
)
|
|
320
329
|
|
|
321
330
|
def _dump_prompt_debug(self, system_prompt: str, user_message: str) -> None:
|
|
322
331
|
"""
|
|
@@ -420,6 +429,15 @@ class LLMClient:
|
|
|
420
429
|
user_message = build_user_message(diff, files_summary, merged_context)
|
|
421
430
|
self._dump_prompt_debug(system_prompt, user_message)
|
|
422
431
|
|
|
432
|
+
self.last_token_estimate = TokenEstimator().estimate(
|
|
433
|
+
provider=self.config.llm_provider,
|
|
434
|
+
model=self.config.get_effective_model(),
|
|
435
|
+
messages=[
|
|
436
|
+
{"role": "system", "content": system_prompt},
|
|
437
|
+
{"role": "user", "content": user_message},
|
|
438
|
+
],
|
|
439
|
+
)
|
|
440
|
+
|
|
423
441
|
provider = self.config.llm_provider.lower()
|
|
424
442
|
|
|
425
443
|
if provider == "openai":
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
# License: Apache-2.0
|
|
2
|
+
# Copyright 2026 Sérgio Sereno
|
|
3
|
+
|
|
1
4
|
"""
|
|
2
5
|
TFS/Azure DevOps Module - AI Code Review
|
|
3
6
|
==========================================
|
|
@@ -15,7 +18,9 @@ Works with both on-premises TFS and Azure DevOps Services.
|
|
|
15
18
|
|
|
16
19
|
import base64
|
|
17
20
|
import difflib
|
|
21
|
+
import hashlib
|
|
18
22
|
import os
|
|
23
|
+
import re
|
|
19
24
|
from typing import Optional
|
|
20
25
|
|
|
21
26
|
from .config import ReviewConfig
|
|
@@ -32,6 +37,12 @@ class TFSClient:
|
|
|
32
37
|
|
|
33
38
|
API_VERSION = "7.0"
|
|
34
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
|
+
|
|
35
46
|
def __init__(self, config: ReviewConfig):
|
|
36
47
|
self.config = config
|
|
37
48
|
self.base_url = config.tfs_base_url.rstrip("/")
|
|
@@ -614,20 +625,36 @@ class TFSClient:
|
|
|
614
625
|
# Pull Requests - Comments
|
|
615
626
|
# ==================================================================
|
|
616
627
|
def post_general_comment(self, repository: str, pr_id: int,
|
|
617
|
-
comment: str, status: str = "active"
|
|
628
|
+
comment: str, status: str = "active",
|
|
629
|
+
dedup_marker: Optional[str] = None) -> dict:
|
|
618
630
|
"""
|
|
619
631
|
Posts a general comment on a Pull Request (not associated with a file).
|
|
620
|
-
|
|
632
|
+
|
|
621
633
|
Args:
|
|
622
634
|
repository: Repository name.
|
|
623
635
|
pr_id: Pull Request ID.
|
|
624
636
|
comment: Comment text (supports Markdown).
|
|
625
637
|
status: Thread status - "active", "fixed", "wontFix",
|
|
626
638
|
"closed", "pending", "byDesign"
|
|
627
|
-
|
|
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
|
+
|
|
628
644
|
Returns:
|
|
629
|
-
Created thread data
|
|
645
|
+
Created thread data, or a ``{"skipped": True, ...}`` dict when the
|
|
646
|
+
comment was skipped due to an already existing marker.
|
|
630
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
|
+
|
|
631
658
|
path = f"git/repositories/{repository}/pullrequests/{pr_id}/threads"
|
|
632
659
|
data = {
|
|
633
660
|
"comments": [
|
|
@@ -726,13 +753,29 @@ class TFSClient:
|
|
|
726
753
|
comment_mode = (comment_mode or "structured").lower()
|
|
727
754
|
use_inline_comments = comment_mode == "structured"
|
|
728
755
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
+
)
|
|
732
761
|
|
|
762
|
+
for c in comments:
|
|
733
763
|
file_path = c.get("file", "")
|
|
734
764
|
line = c.get("line", 0)
|
|
735
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
|
+
|
|
736
779
|
try:
|
|
737
780
|
if use_inline_comments and file_path and line > 0:
|
|
738
781
|
# Inline comment
|
|
@@ -744,6 +787,8 @@ class TFSClient:
|
|
|
744
787
|
result = self.post_general_comment(
|
|
745
788
|
repository, pr_id, text
|
|
746
789
|
)
|
|
790
|
+
# Record the marker to avoid duplicates within the same batch.
|
|
791
|
+
existing_markers.add(marker)
|
|
747
792
|
results.append({
|
|
748
793
|
"success": True,
|
|
749
794
|
"file": file_path,
|
|
@@ -790,6 +835,86 @@ class TFSClient:
|
|
|
790
835
|
|
|
791
836
|
return "\n".join(parts)
|
|
792
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
|
+
|
|
793
918
|
def _status_to_int(self, status: str) -> int:
|
|
794
919
|
"""Converts status string to API integer."""
|
|
795
920
|
status_map = {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# License: Apache-2.0
|
|
2
|
+
# Copyright 2026 Sérgio Sereno
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TokenEstimate:
|
|
10
|
+
"""Result of estimating the input tokens for an LLM request."""
|
|
11
|
+
|
|
12
|
+
total_prompt_tokens: int | None
|
|
13
|
+
model: str
|
|
14
|
+
error: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenEstimator:
|
|
18
|
+
"""Estimates prompt tokens with LiteLLM for configured providers."""
|
|
19
|
+
|
|
20
|
+
_PROVIDER_PREFIXES = {
|
|
21
|
+
"azure_openai": "azure",
|
|
22
|
+
"gemini": "gemini",
|
|
23
|
+
"claude": "anthropic",
|
|
24
|
+
"ollama": "ollama",
|
|
25
|
+
"copilot": "github",
|
|
26
|
+
"bedrock": "bedrock",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def estimate(
|
|
30
|
+
self,
|
|
31
|
+
provider: str,
|
|
32
|
+
model: str,
|
|
33
|
+
messages: list[dict[str, str]],
|
|
34
|
+
) -> TokenEstimate:
|
|
35
|
+
"""Estimates input tokens, falling back to the unqualified model.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
provider: Provider configured for the LLM request.
|
|
39
|
+
model: Effective model name, including configured defaults.
|
|
40
|
+
messages: Complete chat payload sent to the provider.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A token estimate, or an unavailable result when LiteLLM cannot
|
|
44
|
+
resolve either the provider-qualified or raw model name.
|
|
45
|
+
"""
|
|
46
|
+
qualified_model = self._qualified_model(provider, model)
|
|
47
|
+
errors: list[str] = []
|
|
48
|
+
|
|
49
|
+
for candidate in dict.fromkeys((qualified_model, model)):
|
|
50
|
+
try:
|
|
51
|
+
token_count = self._count(candidate, messages)
|
|
52
|
+
return TokenEstimate(token_count, candidate)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
errors.append(f"{candidate}: {exc}")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
token_count = self._count_locally(model, messages)
|
|
58
|
+
return TokenEstimate(token_count, "local/tiktoken")
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
errors.append(f"local/tiktoken: {exc}")
|
|
61
|
+
|
|
62
|
+
return TokenEstimate(
|
|
63
|
+
total_prompt_tokens=None,
|
|
64
|
+
model=qualified_model,
|
|
65
|
+
error="; ".join(errors),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def _qualified_model(cls, provider: str, model: str) -> str:
|
|
70
|
+
"""Builds the LiteLLM model name for a configured provider."""
|
|
71
|
+
normalized_provider = provider.lower().strip()
|
|
72
|
+
prefix = cls._PROVIDER_PREFIXES.get(normalized_provider)
|
|
73
|
+
if not prefix:
|
|
74
|
+
return model
|
|
75
|
+
return f"{prefix}/{model}"
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _count(model: str, messages: list[dict[str, str]]) -> int:
|
|
79
|
+
"""Calls LiteLLM lazily so importing the CLI remains lightweight."""
|
|
80
|
+
from litellm import token_counter
|
|
81
|
+
|
|
82
|
+
return int(token_counter(model=model, messages=messages))
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _count_locally(model: str, messages: list[dict[str, str]]) -> int:
|
|
86
|
+
"""Counts message text locally when LiteLLM has no model support."""
|
|
87
|
+
import tiktoken
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
encoding = tiktoken.encoding_for_model(model)
|
|
91
|
+
except KeyError:
|
|
92
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
93
|
+
|
|
94
|
+
serialized_messages = "\n".join(
|
|
95
|
+
f"{message.get('role', '')}: {message.get('content', '')}"
|
|
96
|
+
for message in messages
|
|
97
|
+
)
|
|
98
|
+
return len(encoding.encode(serialized_messages))
|