open-code-review-toolkit 0.1.0__py3-none-any.whl

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 (38) hide show
  1. ocr_toolkit/__init__.py +1 -0
  2. ocr_toolkit/_version.py +24 -0
  3. ocr_toolkit/cli.py +56 -0
  4. ocr_toolkit/common/__init__.py +1 -0
  5. ocr_toolkit/common/language.py +60 -0
  6. ocr_toolkit/common/markdown.py +214 -0
  7. ocr_toolkit/common/redaction.py +324 -0
  8. ocr_toolkit/config_writer.py +106 -0
  9. ocr_toolkit/configure.py +194 -0
  10. ocr_toolkit/context/__init__.py +1 -0
  11. ocr_toolkit/context/__main__.py +8 -0
  12. ocr_toolkit/context/ansible.py +561 -0
  13. ocr_toolkit/context/categorize.py +162 -0
  14. ocr_toolkit/context/instructions.py +269 -0
  15. ocr_toolkit/context/manifests.py +459 -0
  16. ocr_toolkit/context/planner.py +227 -0
  17. ocr_toolkit/context/render.py +955 -0
  18. ocr_toolkit/context/repo.py +672 -0
  19. ocr_toolkit/context/settings.py +97 -0
  20. ocr_toolkit/mcp_config.py +257 -0
  21. ocr_toolkit/posting/__init__.py +1 -0
  22. ocr_toolkit/posting/__main__.py +8 -0
  23. ocr_toolkit/posting/comments.py +87 -0
  24. ocr_toolkit/posting/formatting.py +755 -0
  25. ocr_toolkit/posting/gitlab.py +853 -0
  26. ocr_toolkit/posting/markers.py +284 -0
  27. ocr_toolkit/posting/payloads.py +141 -0
  28. ocr_toolkit/posting/result.py +116 -0
  29. ocr_toolkit/posting/settings.py +181 -0
  30. ocr_toolkit/posting/snapshot.py +468 -0
  31. ocr_toolkit/posting/workflow.py +873 -0
  32. ocr_toolkit/preflight.py +395 -0
  33. ocr_toolkit/py.typed +1 -0
  34. open_code_review_toolkit-0.1.0.dist-info/METADATA +283 -0
  35. open_code_review_toolkit-0.1.0.dist-info/RECORD +38 -0
  36. open_code_review_toolkit-0.1.0.dist-info/WHEEL +4 -0
  37. open_code_review_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  38. open_code_review_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,284 @@
1
+ """OCR bot note markers, fingerprints, and ownership checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from ocr_toolkit.posting.comments import clean_text, code_text, comment_line, line_number
10
+
11
+ if TYPE_CHECKING:
12
+ from ocr_toolkit.posting.gitlab import GitLabConfig
13
+
14
+ MARKER = "<!-- open-code-review-bot -->"
15
+
16
+
17
+ MARKER_WITH_FINGERPRINT_RE = re.compile(
18
+ r"^<!-- open-code-review-bot(?:\s+fp=([0-9a-f]{8,64}))?\s*-->"
19
+ )
20
+
21
+
22
+ OCR_REPLY_COMMAND_RE = re.compile(r"(?i)\A[ \t]*/ocr[ \t]+(skip|keep)[ \t]*(?:\r?\n[ \t]*)*\Z")
23
+
24
+
25
+ FINGERPRINT_LEN = 32 # hex characters (= 16 raw bytes from blake2b)
26
+
27
+
28
+ def _digest_payload(parts: list[str], digest_size: int = FINGERPRINT_LEN // 2) -> str:
29
+ """Return a blake2b digest for normalized fingerprint fields."""
30
+
31
+ return hashlib.blake2b("\n".join(parts).encode("utf-8"), digest_size=digest_size).hexdigest()
32
+
33
+
34
+ def _normalized_payload_parts(
35
+ comment: dict[str, Any], *, line_based: bool = False
36
+ ) -> list[str] | None:
37
+ """Build shared canonical fingerprint fields, or None for invalid input."""
38
+
39
+ path = clean_text(comment.get("path"))
40
+ if not path:
41
+ return None
42
+
43
+ content = clean_text(comment.get("content"))
44
+ suggestion = code_text(comment.get("suggestion_code"))
45
+ existing = code_text(comment.get("existing_code"))
46
+ category = clean_text(comment.get("category"))
47
+ rule_id = clean_text(comment.get("rule_id"))
48
+ line = comment_line(comment)
49
+ if line_based:
50
+ start_line = line_number(comment.get("start_line") or comment.get("line"))
51
+ return [
52
+ path.strip(),
53
+ str(start_line),
54
+ rule_id.lower(),
55
+ category.lower(),
56
+ " ".join(content.lower().split()),
57
+ " ".join(suggestion.split()),
58
+ ]
59
+
60
+ parts = [
61
+ path.strip(),
62
+ rule_id.lower(),
63
+ category.lower(),
64
+ " ".join(content.lower().split()),
65
+ " ".join(existing.split()),
66
+ " ".join(suggestion.split()),
67
+ ]
68
+ if not existing.strip():
69
+ parts.append(str(line))
70
+
71
+ return parts
72
+
73
+
74
+ def _fingerprint_payload(comment: dict[str, Any]) -> bytes | None:
75
+ """Build the canonical payload bytes for a fingerprint, or None."""
76
+
77
+ parts = _normalized_payload_parts(comment)
78
+ if parts is None:
79
+ return None
80
+ return "\n".join(parts).encode("utf-8")
81
+
82
+
83
+ def _line_based_fingerprint_payload(comment: dict[str, Any]) -> bytes | None:
84
+ """Build the previous line-based payload for compatibility checks."""
85
+
86
+ parts = _normalized_payload_parts(comment, line_based=True)
87
+ if parts is None:
88
+ return None
89
+ return "\n".join(parts).encode("utf-8")
90
+
91
+
92
+ def comment_fingerprint(comment: dict[str, Any]) -> str | None:
93
+ """Return a stable short hash describing one OCR finding.
94
+
95
+ The hash combines path, normalized review text, and the commented code
96
+ fragment (`existing_code`) when OCR provides it. It omits the anchor line
97
+ only when that code anchor exists, so resolved findings and `/ocr skip`
98
+ survive ordinary line shifts without broadening generic no-code comments
99
+ across a whole file. Returns None for comments missing a usable path.
100
+
101
+ Uses 16 raw bytes (32 hex chars) to keep birthday-collision risk
102
+ well below MR sizes the bot is likely to see. The marker regex
103
+ accepts 8–64 hex chars, so the wider hash is forward-compatible.
104
+ Older markers used a line-based payload; filter code checks those
105
+ compatibility hashes alongside this value.
106
+ """
107
+
108
+ payload = _fingerprint_payload(comment)
109
+ if payload is None:
110
+ return None
111
+ return hashlib.blake2b(payload, digest_size=FINGERPRINT_LEN // 2).hexdigest()
112
+
113
+
114
+ def line_based_comment_fingerprint(comment: dict[str, Any]) -> str | None:
115
+ """Return the previous 32-hex line-based fingerprint for compatibility."""
116
+
117
+ payload = _line_based_fingerprint_payload(comment)
118
+ if payload is None:
119
+ return None
120
+ return hashlib.blake2b(payload, digest_size=FINGERPRINT_LEN // 2).hexdigest()
121
+
122
+
123
+ def occurrence_comment_fingerprint(comment: dict[str, Any], occurrence_index: int) -> str | None:
124
+ """Return a fingerprint that separates repeated identical findings."""
125
+
126
+ base = comment_fingerprint(comment)
127
+ if base is None or occurrence_index <= 0:
128
+ return base
129
+
130
+ return _digest_payload([base, f"occurrence:{occurrence_index}"])
131
+
132
+
133
+ def annotate_comment_fingerprints(
134
+ comments: list[dict[str, Any]], existing_key: str = "_ocr_fingerprint"
135
+ ) -> None:
136
+ """Attach occurrence-aware fingerprints to OCR comments in posting order."""
137
+
138
+ grouped: dict[str, list[tuple[int, dict[str, Any]]]] = {}
139
+ for index, comment in enumerate(comments):
140
+ base = comment_fingerprint(comment)
141
+ if base is None:
142
+ comment.pop(existing_key, None)
143
+ continue
144
+ grouped.setdefault(base, []).append((index, comment))
145
+
146
+ for base, indexed_comments in grouped.items():
147
+ ordered_comments = sorted(
148
+ indexed_comments,
149
+ key=lambda item: (
150
+ comment_line(item[1]),
151
+ clean_text(item[1].get("path")),
152
+ code_text(item[1].get("existing_code")),
153
+ code_text(item[1].get("suggestion_code")),
154
+ item[0],
155
+ ),
156
+ )
157
+ for occurrence, (_index, comment) in enumerate(ordered_comments):
158
+ comment[existing_key] = occurrence_comment_fingerprint(comment, occurrence)
159
+
160
+
161
+ def legacy_comment_fingerprint(comment: dict[str, Any]) -> str | None:
162
+ """Return the pre-migration 16-hex fingerprint for one OCR finding.
163
+
164
+ Why: markers produced before the 8→16 byte digest migration are still
165
+ stored in resolved/skip-flagged threads. Without this companion,
166
+ `/ocr skip` decisions made under the old length would silently stop
167
+ suppressing their findings after the migration. Same line-based
168
+ payload as the previous fingerprint implementation, narrower digest.
169
+ """
170
+
171
+ payload = _line_based_fingerprint_payload(comment)
172
+ if payload is None:
173
+ return None
174
+ return hashlib.blake2b(payload, digest_size=8).hexdigest()
175
+
176
+
177
+ def comment_fingerprint_candidates(comment: dict[str, Any]) -> set[str]:
178
+ """Return current and backward-compatible fingerprints for a comment."""
179
+
180
+ annotated = comment.get("_ocr_fingerprint")
181
+ if isinstance(annotated, str) and annotated:
182
+ # For duplicate findings, the annotated marker is the current run's
183
+ # identity. Do not also include the base hash: a skip on the first
184
+ # duplicate would suppress every later duplicate in the same file.
185
+ candidates = {annotated}
186
+ if line_number(comment.get("start_line") or comment.get("line")) > 0:
187
+ line_fingerprint = line_based_comment_fingerprint(comment)
188
+ legacy_fingerprint = legacy_comment_fingerprint(comment)
189
+ if line_fingerprint:
190
+ candidates.add(line_fingerprint)
191
+ if legacy_fingerprint:
192
+ candidates.add(legacy_fingerprint)
193
+ return {fingerprint for fingerprint in candidates if fingerprint}
194
+
195
+ candidates = set(
196
+ filter(
197
+ None,
198
+ (
199
+ comment_fingerprint(comment),
200
+ line_based_comment_fingerprint(comment),
201
+ legacy_comment_fingerprint(comment),
202
+ ),
203
+ )
204
+ )
205
+ return {fingerprint for fingerprint in candidates if fingerprint}
206
+
207
+
208
+ def build_marker(fingerprint: str | None) -> str:
209
+ """Render the OCR marker, embedding the fingerprint when available."""
210
+
211
+ if fingerprint:
212
+ return f"<!-- open-code-review-bot fp={fingerprint} -->"
213
+ return MARKER
214
+
215
+
216
+ def note_starts_with_marker(body: str) -> bool:
217
+ """Return whether a note body is owned by this script format."""
218
+
219
+ return bool(MARKER_WITH_FINGERPRINT_RE.match(body))
220
+
221
+
222
+ def fingerprint_from_marker(body: str) -> str | None:
223
+ """Return the fingerprint embedded in a bot marker, if any."""
224
+
225
+ match = MARKER_WITH_FINGERPRINT_RE.match(body)
226
+ if not match:
227
+ return None
228
+ return match.group(1)
229
+
230
+
231
+ def author_id_from_note(note: dict[str, Any]) -> int | None:
232
+ """Extract author id from a published note or draft note object."""
233
+
234
+ raw_author_id = note.get("author_id")
235
+
236
+ if raw_author_id is None:
237
+ author = note.get("author")
238
+ if isinstance(author, dict):
239
+ raw_author_id = author.get("id")
240
+
241
+ try:
242
+ return int(raw_author_id)
243
+ except (TypeError, ValueError):
244
+ return None
245
+
246
+
247
+ def is_diff_note(note: dict[str, Any]) -> bool:
248
+ """Return True if a note belongs to an inline (diff) discussion.
249
+
250
+ Such notes are owned by the /discussions cycle and must NOT be
251
+ deleted via DELETE /notes/{id}, otherwise a reviewer-preserved
252
+ thread (resolved, /ocr skip, /ocr keep) gets destroyed despite our
253
+ decision to keep it.
254
+ """
255
+
256
+ if note.get("type") == "DiffNote":
257
+ return True
258
+ if isinstance(note.get("position"), dict):
259
+ return True
260
+ return False
261
+
262
+
263
+ def is_own_bot_note(config: GitLabConfig, note: dict[str, Any], body_field: str) -> bool:
264
+ """Return whether a note belongs to this bot and this script.
265
+
266
+ Authorship must be positively verifiable. If `current_user_id` could
267
+ not be resolved (see `print_user_id_failure_banner`) or the note has
268
+ no author id, refuse to claim ownership — otherwise this bot might
269
+ delete notes written by other users whose body happens to start with
270
+ the same MARKER.
271
+ """
272
+
273
+ body = str(note.get(body_field) or "")
274
+ if not note_starts_with_marker(body):
275
+ return False
276
+
277
+ if config.current_user_id is None:
278
+ return False
279
+
280
+ actual_author_id = author_id_from_note(note)
281
+ if actual_author_id is None:
282
+ return False
283
+
284
+ return actual_author_id == config.current_user_id
@@ -0,0 +1,141 @@
1
+ """GitLab note payload size limiting and marker wrapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ocr_toolkit.common.markdown import markdown_code_block
6
+ from ocr_toolkit.posting.markers import build_marker
7
+ from ocr_toolkit.posting.settings import (
8
+ MAX_INLINE_NOTE_CHARS,
9
+ MAX_NOTE_CHARS,
10
+ SUGGESTION_BLOCK_RE,
11
+ TRUNCATION_NOTICE,
12
+ )
13
+
14
+
15
+ def _fits_limit(text: str, max_chars: int) -> bool:
16
+ return len(text) <= max_chars and len(text.encode("utf-8")) <= max_chars
17
+
18
+
19
+ def _utf8_prefix(text: str, max_bytes: int) -> str:
20
+ if max_bytes <= 0:
21
+ return ""
22
+ return text.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")
23
+
24
+
25
+ def truncate_note_body(body: str, max_chars: int = MAX_NOTE_CHARS) -> str:
26
+ """Truncate a GitLab note body without splitting Markdown structures."""
27
+
28
+ if _fits_limit(body, max_chars):
29
+ return body
30
+
31
+ notice = f"{TRUNCATION_NOTICE}\n\nRaw Markdown excerpt follows."
32
+ budget = max(0, max_chars - len(notice.encode("utf-8")) - 2)
33
+ excerpt = _utf8_prefix(body, budget).rstrip()
34
+
35
+ while excerpt:
36
+ result = f"{notice}\n\n{markdown_code_block('markdown', excerpt)}"
37
+ if _fits_limit(result, max_chars):
38
+ return result
39
+
40
+ overflow = max(len(result) - max_chars, len(result.encode("utf-8")) - max_chars)
41
+ excerpt = _utf8_prefix(
42
+ excerpt, max(0, len(excerpt.encode("utf-8")) - overflow - 1)
43
+ ).rstrip()
44
+
45
+ return notice[:max_chars]
46
+
47
+
48
+ def truncate_plain_text(text: str, max_chars: int) -> str:
49
+ """Truncate plain text with a short notice."""
50
+
51
+ if _fits_limit(text, max_chars):
52
+ return text
53
+
54
+ notice = "\n\nText was truncated before publishing to GitLab."
55
+ if max_chars <= len(notice.encode("utf-8")):
56
+ return _utf8_prefix(notice, max_chars)
57
+
58
+ budget = max(0, max_chars - len(notice.encode("utf-8")))
59
+ return _utf8_prefix(text, budget).rstrip() + notice
60
+
61
+
62
+ def truncate_code_text(text: str, max_chars: int) -> str:
63
+ """Truncate code text while preserving indentation as much as possible."""
64
+
65
+ if max_chars <= 0:
66
+ return ""
67
+
68
+ if _fits_limit(text, max_chars):
69
+ return text
70
+
71
+ notice = "\n# ... truncated before publishing to GitLab ..."
72
+ if max_chars <= len(notice.encode("utf-8")):
73
+ return _utf8_prefix(notice, max_chars)
74
+
75
+ budget = max(0, max_chars - len(notice.encode("utf-8")))
76
+ return _utf8_prefix(text, budget).rstrip("\n") + notice
77
+
78
+
79
+ def limit_inline_body(body: str, max_chars: int = MAX_INLINE_NOTE_CHARS) -> str:
80
+ """Limit inline discussion body without breaking GitLab suggestion blocks.
81
+
82
+ If the inline body is too large, drop the suggestion block first. If the
83
+ plain body is still too large, truncate it as plain Markdown text.
84
+ """
85
+
86
+ if _fits_limit(body, max_chars):
87
+ return body
88
+
89
+ match = SUGGESTION_BLOCK_RE.search(body)
90
+ if match:
91
+ body_without_suggestion = body[: match.start()] + (
92
+ "\n\nSuggestion block was omitted because the inline comment exceeded "
93
+ "the safe GitLab note size."
94
+ )
95
+ if _fits_limit(body_without_suggestion, max_chars):
96
+ return body_without_suggestion
97
+
98
+ body = body_without_suggestion
99
+
100
+ return truncate_plain_text(body, max_chars)
101
+
102
+
103
+ def limit_regular_note_body(body: str, max_chars: int = MAX_NOTE_CHARS) -> str:
104
+ """Limit a regular MR note body before calling the GitLab API."""
105
+
106
+ return truncate_note_body(body, max_chars=max_chars)
107
+
108
+
109
+ def note_body_budget(max_chars: int, fingerprint: str | None = None) -> int:
110
+ """Return the body budget after reserving space for the bot marker."""
111
+
112
+ return max(0, max_chars - len(build_marker(fingerprint)) - 1)
113
+
114
+
115
+ def build_marked_note_body(
116
+ body: str,
117
+ *,
118
+ fingerprint: str | None = None,
119
+ max_chars: int = MAX_NOTE_CHARS,
120
+ inline: bool = False,
121
+ ) -> str:
122
+ """Build the exact GitLab note payload, including marker and size limit."""
123
+
124
+ marker = build_marker(fingerprint)
125
+ if max_chars < len(marker) + 1:
126
+ raise ValueError("GitLab note payload budget is too small for OCR marker")
127
+
128
+ budget = note_body_budget(max_chars, fingerprint)
129
+ limited_body = (
130
+ limit_inline_body(body, max_chars=budget)
131
+ if inline
132
+ else limit_regular_note_body(body, max_chars=budget)
133
+ )
134
+ payload = f"{marker}\n{limited_body}"
135
+ if _fits_limit(payload, max_chars):
136
+ return payload
137
+
138
+ # Defensive fallback: the limiting helpers should already respect
139
+ # the marker-aware budget, but never send an over-limit API payload.
140
+ limited_body = truncate_plain_text(limited_body, budget)
141
+ return f"{marker}\n{limited_body}"
@@ -0,0 +1,116 @@
1
+ """OCR result artifact loading and provider failure classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ocr_toolkit.common.redaction import sanitize_ocr_value
12
+ from ocr_toolkit.posting.comments import clean_text
13
+ from ocr_toolkit.posting.settings import max_result_bytes
14
+
15
+
16
+ class OcrResultMissing(Exception):
17
+ """The OCR result artifact is missing or unreadable on disk."""
18
+
19
+
20
+ class OcrResultMalformed(Exception):
21
+ """The OCR result artifact exists but is not valid JSON."""
22
+
23
+
24
+ class OcrResultTooLarge(Exception):
25
+ """The OCR result artifact exceeds the configured safety limit."""
26
+
27
+
28
+ def load_ocr_result(path: Path) -> Any:
29
+ """Load OCR JSON result from disk.
30
+
31
+ Raises `OcrResultMissing` when the artifact is absent or
32
+ unreadable, `OcrResultTooLarge` before reading a runaway artifact,
33
+ and `OcrResultMalformed` when the contents are not valid JSON. The
34
+ caller routes these failure modes differently: a missing artifact
35
+ looks like an OCR crash, while an oversized or malformed artifact is
36
+ a controlled OCR output contract violation.
37
+ """
38
+
39
+ try:
40
+ size = path.stat().st_size
41
+ except FileNotFoundError as exc:
42
+ raise OcrResultMissing(str(exc)) from exc
43
+ except OSError as exc:
44
+ raise OcrResultMissing(str(exc)) from exc
45
+
46
+ limit = max_result_bytes()
47
+ if size > limit:
48
+ raise OcrResultTooLarge(
49
+ f"OCR result JSON is {size} bytes, above OCR_MAX_RESULT_BYTES={limit}"
50
+ )
51
+
52
+ try:
53
+ with path.open("rb") as handle:
54
+ data = handle.read(limit + 1)
55
+ except FileNotFoundError as exc:
56
+ raise OcrResultMissing(str(exc)) from exc
57
+ except OSError as exc:
58
+ raise OcrResultMissing(str(exc)) from exc
59
+ if len(data) > limit:
60
+ raise OcrResultTooLarge(f"OCR result JSON grew above OCR_MAX_RESULT_BYTES={limit}")
61
+
62
+ try:
63
+ text = data.decode("utf-8")
64
+ except UnicodeDecodeError as exc:
65
+ raise OcrResultMalformed(str(exc)) from exc
66
+
67
+ try:
68
+ return sanitize_ocr_value(json.loads(text))
69
+ except (json.JSONDecodeError, RecursionError) as exc:
70
+ raise OcrResultMalformed(str(exc)) from exc
71
+
72
+
73
+ LLM_BILLING_FAILURE_RE = re.compile(
74
+ r"(?i)\b("
75
+ r"(?:http\s*)?status(?:[_\s]*code)?[\"']?\s*[:=]\s*[\"']?402|"
76
+ r"code[\"']?\s*[:=]\s*[\"']?402|payment required|insufficient[_ -]?funds|insufficient user balance|"
77
+ r"insufficient balance|insufficient[_ -]?quota|quota[_ -]?exceeded|"
78
+ r"out of credits|credit balance"
79
+ r")\b"
80
+ )
81
+
82
+
83
+ def ocr_warning_text(warning: Any) -> str:
84
+ """Return warning text relevant for provider failure classification."""
85
+
86
+ if isinstance(warning, dict):
87
+ parts: list[str] = []
88
+ for key in ("type", "message", "code", "status", "status_code", "detail"):
89
+ text = clean_text(warning.get(key))
90
+ if text:
91
+ parts.append(f"{key}: {text}" if key in {"code", "status", "status_code"} else text)
92
+ for key in ("error", "details"):
93
+ nested = warning.get(key)
94
+ if isinstance(nested, dict):
95
+ text = ocr_warning_text(nested)
96
+ if text:
97
+ parts.append(text)
98
+ else:
99
+ text = clean_text(nested)
100
+ if text:
101
+ parts.append(text)
102
+ return "\n".join(parts)[:4000]
103
+ if isinstance(warning, list):
104
+ return "\n".join(text for value in warning[:40] if (text := ocr_warning_text(value)))[:4000]
105
+ return clean_text(warning)
106
+
107
+
108
+ def llm_billing_failure_warnings(warnings: Sequence[Any]) -> list[str]:
109
+ """Return OCR warnings that indicate LLM provider billing/quota failure."""
110
+
111
+ matches: list[str] = []
112
+ for warning in warnings:
113
+ text = ocr_warning_text(warning)
114
+ if text and LLM_BILLING_FAILURE_RE.search(text):
115
+ matches.append(text)
116
+ return matches