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.
Files changed (31) hide show
  1. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/PKG-INFO +11 -1
  2. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/README.md +6 -0
  3. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/PKG-INFO +11 -1
  4. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/SOURCES.txt +3 -1
  5. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/code_review_ai_cli.egg-info/requires.txt +4 -0
  6. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/pyproject.toml +5 -1
  7. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/__init__.py +3 -0
  8. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/ai_review.py +22 -2
  9. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/config.py +3 -0
  10. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/context_extractor.py +3 -0
  11. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/formatter.py +6 -1
  12. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/git_utils.py +3 -0
  13. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/llm_client.py +18 -0
  14. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/prompt_utils.py +3 -0
  15. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/tfs_client.py +132 -7
  16. code_review_ai_cli-2.2.4/src/token_estimator.py +98 -0
  17. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_ai_review.py +3 -0
  18. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_config.py +389 -386
  19. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_context_extractor.py +3 -0
  20. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_formatter.py +5 -1
  21. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_git_utils.py +159 -156
  22. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_llm_client.py +3 -0
  23. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_prompt_utils.py +3 -0
  24. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/tests/test_tfs_client.py +114 -0
  25. code_review_ai_cli-2.2.4/tests/test_token_estimator.py +102 -0
  26. {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
  27. {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
  28. {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
  29. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/setup.cfg +0 -0
  30. {code_review_ai_cli-2.2.2 → code_review_ai_cli-2.2.4}/src/prompts/config.yaml.template +0 -0
  31. {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.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
  ![Review Output Format Example](/imgs/review_output.png)
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
  ![Review Output Format Example](/imgs/review_output.png)
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.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
  ![Review Output Format Example](/imgs/review_output.png)
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).
@@ -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
@@ -1,8 +1,12 @@
1
1
  requests>=2.28.0
2
2
  PyYAML>=6.0
3
+ litellm>=1.40.0
4
+ tiktoken>=0.7.0
3
5
 
4
6
  [all]
5
7
  boto3>=1.34.0
8
+ litellm>=1.40.0
9
+ tiktoken>=0.7.0
6
10
  openai>=1.0.0
7
11
  google-generativeai>=0.3.0
8
12
  anthropic>=0.18.0
@@ -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.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,3 +1,6 @@
1
+ # License: Apache-2.0
2
+ # Copyright 2026 Sérgio Sereno
3
+
1
4
  # AI Code Review - Internal modules
2
5
  from importlib.metadata import version, PackageNotFoundError
3
6
 
@@ -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(repo_name, pr_id, summary_comment)
586
- 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."))
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
  Configuration Module - AI Code Review
3
6
  =======================================
@@ -1,3 +1,6 @@
1
+ # License: Apache-2.0
2
+ # Copyright 2026 Sérgio Sereno
3
+
1
4
  """
2
5
  Context Extractor Module - AI Code Review
3
6
  ==========================================
@@ -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
- 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
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
  Git Utilities Module - AI Code Review
3
6
  ========================================
@@ -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
  Prompt Utilities Module - AI Code Review
3
6
  ========================================
@@ -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") -> dict:
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
- for c in comments:
730
- # Build formatted comment text
731
- 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
+ )
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))
@@ -1,3 +1,6 @@
1
+ # License: Apache-2.0
2
+ # Copyright 2026 Sérgio Sereno
3
+
1
4
  """Tests for the AI review CLI workflow."""
2
5
 
3
6
  from __future__ import annotations