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.
- ocr_toolkit/__init__.py +1 -0
- ocr_toolkit/_version.py +24 -0
- ocr_toolkit/cli.py +56 -0
- ocr_toolkit/common/__init__.py +1 -0
- ocr_toolkit/common/language.py +60 -0
- ocr_toolkit/common/markdown.py +214 -0
- ocr_toolkit/common/redaction.py +324 -0
- ocr_toolkit/config_writer.py +106 -0
- ocr_toolkit/configure.py +194 -0
- ocr_toolkit/context/__init__.py +1 -0
- ocr_toolkit/context/__main__.py +8 -0
- ocr_toolkit/context/ansible.py +561 -0
- ocr_toolkit/context/categorize.py +162 -0
- ocr_toolkit/context/instructions.py +269 -0
- ocr_toolkit/context/manifests.py +459 -0
- ocr_toolkit/context/planner.py +227 -0
- ocr_toolkit/context/render.py +955 -0
- ocr_toolkit/context/repo.py +672 -0
- ocr_toolkit/context/settings.py +97 -0
- ocr_toolkit/mcp_config.py +257 -0
- ocr_toolkit/posting/__init__.py +1 -0
- ocr_toolkit/posting/__main__.py +8 -0
- ocr_toolkit/posting/comments.py +87 -0
- ocr_toolkit/posting/formatting.py +755 -0
- ocr_toolkit/posting/gitlab.py +853 -0
- ocr_toolkit/posting/markers.py +284 -0
- ocr_toolkit/posting/payloads.py +141 -0
- ocr_toolkit/posting/result.py +116 -0
- ocr_toolkit/posting/settings.py +181 -0
- ocr_toolkit/posting/snapshot.py +468 -0
- ocr_toolkit/posting/workflow.py +873 -0
- ocr_toolkit/preflight.py +395 -0
- ocr_toolkit/py.typed +1 -0
- open_code_review_toolkit-0.1.0.dist-info/METADATA +283 -0
- open_code_review_toolkit-0.1.0.dist-info/RECORD +38 -0
- open_code_review_toolkit-0.1.0.dist-info/WHEEL +4 -0
- open_code_review_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- open_code_review_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""High-level OCR result posting workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ocr_toolkit.common.markdown import markdown_code_block, neutralize_quick_actions
|
|
15
|
+
from ocr_toolkit.common.redaction import redact_sensitive
|
|
16
|
+
from ocr_toolkit.posting import gitlab as gitlab_api
|
|
17
|
+
from ocr_toolkit.posting.comments import clean_text, code_text, comment_line, compact_escaped_text
|
|
18
|
+
from ocr_toolkit.posting.formatting import (
|
|
19
|
+
format_fallback_comment_chunks,
|
|
20
|
+
format_inline_comment,
|
|
21
|
+
format_omitted_comments_summary,
|
|
22
|
+
format_reviewer_guide,
|
|
23
|
+
format_token_usage_summary,
|
|
24
|
+
format_tool_calls_summary,
|
|
25
|
+
inline_code,
|
|
26
|
+
summarize_result,
|
|
27
|
+
)
|
|
28
|
+
from ocr_toolkit.posting.gitlab import (
|
|
29
|
+
GitLabConfig,
|
|
30
|
+
get_diff_refs,
|
|
31
|
+
load_gitlab_config,
|
|
32
|
+
post_review_discussion,
|
|
33
|
+
post_review_note_bounded,
|
|
34
|
+
publish_created_draft_notes,
|
|
35
|
+
resolve_discussion,
|
|
36
|
+
)
|
|
37
|
+
from ocr_toolkit.posting.markers import annotate_comment_fingerprints
|
|
38
|
+
from ocr_toolkit.posting.result import (
|
|
39
|
+
OcrResultMalformed,
|
|
40
|
+
OcrResultMissing,
|
|
41
|
+
OcrResultTooLarge,
|
|
42
|
+
llm_billing_failure_warnings,
|
|
43
|
+
load_ocr_result,
|
|
44
|
+
)
|
|
45
|
+
from ocr_toolkit.posting.snapshot import (
|
|
46
|
+
BotCommentRefs,
|
|
47
|
+
cleanup_drafts_created_by_this_run,
|
|
48
|
+
collect_previous_bot_comment_refs,
|
|
49
|
+
delete_previous_bot_comments_if_collected,
|
|
50
|
+
filter_skipped_comments,
|
|
51
|
+
posting_failure_exit,
|
|
52
|
+
print_posting_failure_banner,
|
|
53
|
+
publish_failure_exit,
|
|
54
|
+
rollback_current_run_comments,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Kept as a module-level compatibility seam for tests and external monkey-patching.
|
|
58
|
+
post_review_note = gitlab_api.post_review_note
|
|
59
|
+
from ocr_toolkit.posting.settings import (
|
|
60
|
+
max_post_comments,
|
|
61
|
+
ocr_exit_code,
|
|
62
|
+
post_mode,
|
|
63
|
+
strict_posting,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def finalize_posting(config: GitLabConfig, draft_note_ids: list[int]) -> bool:
|
|
68
|
+
"""Publish draft notes created by this script run."""
|
|
69
|
+
|
|
70
|
+
return publish_created_draft_notes(config, draft_note_ids)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def inline_skip_reason(refs: dict[str, str] | None, path: str, line: int) -> str:
|
|
74
|
+
"""Return why an OCR comment cannot be posted inline before API posting."""
|
|
75
|
+
|
|
76
|
+
if refs is None:
|
|
77
|
+
return "missing_diff_refs"
|
|
78
|
+
if not path:
|
|
79
|
+
return "missing_path"
|
|
80
|
+
if line <= 0:
|
|
81
|
+
return "missing_line"
|
|
82
|
+
return "unknown"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def reviewed_sha() -> str:
|
|
86
|
+
"""Return the commit SHA OCR was expected to review in CI."""
|
|
87
|
+
|
|
88
|
+
source_sha = clean_text(os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", ""))
|
|
89
|
+
if source_sha and not re.fullmatch(r"0+", source_sha):
|
|
90
|
+
return source_sha
|
|
91
|
+
return clean_text(os.environ.get("CI_COMMIT_SHA", ""))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def mr_head_sha() -> str:
|
|
95
|
+
"""Return the MR source-branch head SHA when GitLab provides it."""
|
|
96
|
+
|
|
97
|
+
return clean_text(os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", ""))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
DiffLineCache = dict[tuple[str, str, str], set[int]]
|
|
101
|
+
FileLineCache = dict[tuple[str, str], list[tuple[int, str]]]
|
|
102
|
+
ChangedPathCache = dict[tuple[str, str], list[str]]
|
|
103
|
+
MAX_CROSS_FILE_REMAP_PATHS = 200
|
|
104
|
+
MAX_REMAP_FILE_BYTES = 2_000_000
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def changed_new_lines(
|
|
108
|
+
refs: dict[str, str], path: str, cache: DiffLineCache | None = None
|
|
109
|
+
) -> set[int]:
|
|
110
|
+
"""Return changed new-file lines for a path from the reviewed Git diff."""
|
|
111
|
+
|
|
112
|
+
cache_key = (refs["base_sha"], refs["head_sha"], path)
|
|
113
|
+
if cache is not None and cache_key in cache:
|
|
114
|
+
return cache[cache_key]
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
[
|
|
119
|
+
"git",
|
|
120
|
+
"diff",
|
|
121
|
+
"--unified=0",
|
|
122
|
+
refs["base_sha"],
|
|
123
|
+
refs["head_sha"],
|
|
124
|
+
"--",
|
|
125
|
+
path,
|
|
126
|
+
],
|
|
127
|
+
check=False,
|
|
128
|
+
stdout=subprocess.PIPE,
|
|
129
|
+
stderr=subprocess.DEVNULL,
|
|
130
|
+
text=True,
|
|
131
|
+
timeout=15,
|
|
132
|
+
)
|
|
133
|
+
except (OSError, subprocess.SubprocessError):
|
|
134
|
+
lines = set()
|
|
135
|
+
if cache is not None:
|
|
136
|
+
cache[cache_key] = lines
|
|
137
|
+
return lines
|
|
138
|
+
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
lines = set()
|
|
141
|
+
if cache is not None:
|
|
142
|
+
cache[cache_key] = lines
|
|
143
|
+
return lines
|
|
144
|
+
|
|
145
|
+
lines = set()
|
|
146
|
+
for match in re.finditer(r"(?m)^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", result.stdout):
|
|
147
|
+
start = int(match.group(1))
|
|
148
|
+
length = int(match.group(2) or "1")
|
|
149
|
+
if start <= 0 or length <= 0:
|
|
150
|
+
continue
|
|
151
|
+
lines.update(range(start, start + length))
|
|
152
|
+
if cache is not None:
|
|
153
|
+
cache[cache_key] = lines
|
|
154
|
+
return lines
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def changed_new_paths(refs: dict[str, str], cache: ChangedPathCache | None = None) -> list[str]:
|
|
158
|
+
"""Return changed new-file paths for the reviewed Git diff."""
|
|
159
|
+
|
|
160
|
+
cache_key = (refs["base_sha"], refs["head_sha"])
|
|
161
|
+
if cache is not None and cache_key in cache:
|
|
162
|
+
return cache[cache_key]
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
[
|
|
167
|
+
"git",
|
|
168
|
+
"diff",
|
|
169
|
+
"--name-only",
|
|
170
|
+
"--diff-filter=ACMR",
|
|
171
|
+
"-z",
|
|
172
|
+
refs["base_sha"],
|
|
173
|
+
refs["head_sha"],
|
|
174
|
+
],
|
|
175
|
+
check=False,
|
|
176
|
+
stdout=subprocess.PIPE,
|
|
177
|
+
stderr=subprocess.DEVNULL,
|
|
178
|
+
text=True,
|
|
179
|
+
timeout=15,
|
|
180
|
+
)
|
|
181
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
182
|
+
paths: list[str] = []
|
|
183
|
+
if cache is not None:
|
|
184
|
+
cache[cache_key] = paths
|
|
185
|
+
return paths
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
paths = []
|
|
188
|
+
if cache is not None:
|
|
189
|
+
cache[cache_key] = paths
|
|
190
|
+
return paths
|
|
191
|
+
|
|
192
|
+
paths = sorted(path for path in result.stdout.split("\0") if path)
|
|
193
|
+
if cache is not None:
|
|
194
|
+
cache[cache_key] = paths
|
|
195
|
+
return paths
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def head_file_lines(
|
|
199
|
+
refs: dict[str, str], path: str, cache: FileLineCache | None = None
|
|
200
|
+
) -> list[tuple[int, str]]:
|
|
201
|
+
"""Return non-blank head file lines with original 1-based line numbers."""
|
|
202
|
+
|
|
203
|
+
cache_key = (refs["head_sha"], path)
|
|
204
|
+
if cache is not None and cache_key in cache:
|
|
205
|
+
return cache[cache_key]
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
size_result = subprocess.run(
|
|
209
|
+
["git", "cat-file", "-s", f"{refs['head_sha']}:{path}"],
|
|
210
|
+
check=False,
|
|
211
|
+
stdout=subprocess.PIPE,
|
|
212
|
+
stderr=subprocess.DEVNULL,
|
|
213
|
+
text=True,
|
|
214
|
+
timeout=15,
|
|
215
|
+
)
|
|
216
|
+
if (
|
|
217
|
+
size_result.returncode != 0
|
|
218
|
+
or int(size_result.stdout.strip() or "0") > MAX_REMAP_FILE_BYTES
|
|
219
|
+
):
|
|
220
|
+
lines = []
|
|
221
|
+
if cache is not None:
|
|
222
|
+
cache[cache_key] = lines
|
|
223
|
+
return lines
|
|
224
|
+
file_text = subprocess.run(
|
|
225
|
+
["git", "show", f"{refs['head_sha']}:{path}"],
|
|
226
|
+
check=False,
|
|
227
|
+
stdout=subprocess.PIPE,
|
|
228
|
+
stderr=subprocess.DEVNULL,
|
|
229
|
+
timeout=15,
|
|
230
|
+
)
|
|
231
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
232
|
+
lines = []
|
|
233
|
+
if cache is not None:
|
|
234
|
+
cache[cache_key] = lines
|
|
235
|
+
return lines
|
|
236
|
+
if file_text.returncode != 0:
|
|
237
|
+
lines = []
|
|
238
|
+
if cache is not None:
|
|
239
|
+
cache[cache_key] = lines
|
|
240
|
+
return lines
|
|
241
|
+
|
|
242
|
+
lines = [
|
|
243
|
+
(line_number, line.rstrip())
|
|
244
|
+
for line_number, line in enumerate(
|
|
245
|
+
file_text.stdout.decode("utf-8", errors="replace").splitlines(), start=1
|
|
246
|
+
)
|
|
247
|
+
if line.strip()
|
|
248
|
+
]
|
|
249
|
+
if cache is not None:
|
|
250
|
+
cache[cache_key] = lines
|
|
251
|
+
return lines
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def unique_existing_code_line(
|
|
255
|
+
refs: dict[str, str],
|
|
256
|
+
path: str,
|
|
257
|
+
comment: dict[str, Any],
|
|
258
|
+
*,
|
|
259
|
+
diff_line_cache: DiffLineCache | None = None,
|
|
260
|
+
file_line_cache: FileLineCache | None = None,
|
|
261
|
+
) -> int:
|
|
262
|
+
"""Map existing_code to one changed line when the match is unambiguous."""
|
|
263
|
+
|
|
264
|
+
existing = code_text(comment.get("existing_code")).strip("\n")
|
|
265
|
+
if not existing:
|
|
266
|
+
return 0
|
|
267
|
+
needle = [line.rstrip() for line in existing.splitlines() if line.strip()]
|
|
268
|
+
if not needle:
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
changed_lines = changed_new_lines(refs, path, diff_line_cache)
|
|
272
|
+
if not changed_lines:
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
haystack = head_file_lines(refs, path, file_line_cache)
|
|
276
|
+
if not haystack:
|
|
277
|
+
return 0
|
|
278
|
+
matches: list[int] = []
|
|
279
|
+
width = len(needle)
|
|
280
|
+
for index in range(0, max(0, len(haystack) - width + 1)):
|
|
281
|
+
window = haystack[index : index + width]
|
|
282
|
+
if [line_text for _, line_text in window] != needle:
|
|
283
|
+
continue
|
|
284
|
+
changed_anchor = next(
|
|
285
|
+
(line_number for line_number, _ in window if line_number in changed_lines),
|
|
286
|
+
0,
|
|
287
|
+
)
|
|
288
|
+
if changed_anchor > 0:
|
|
289
|
+
matches.append(changed_anchor)
|
|
290
|
+
return matches[0] if len(matches) == 1 else 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def remap_existing_code_location(
|
|
294
|
+
refs: dict[str, str],
|
|
295
|
+
path: str,
|
|
296
|
+
comment: dict[str, Any],
|
|
297
|
+
*,
|
|
298
|
+
diff_line_cache: DiffLineCache,
|
|
299
|
+
file_line_cache: FileLineCache,
|
|
300
|
+
changed_path_cache: ChangedPathCache,
|
|
301
|
+
) -> tuple[str, int]:
|
|
302
|
+
"""Map an anchorless OCR finding to one changed path and line when unambiguous."""
|
|
303
|
+
|
|
304
|
+
if path:
|
|
305
|
+
line = unique_existing_code_line(
|
|
306
|
+
refs,
|
|
307
|
+
path,
|
|
308
|
+
comment,
|
|
309
|
+
diff_line_cache=diff_line_cache,
|
|
310
|
+
file_line_cache=file_line_cache,
|
|
311
|
+
)
|
|
312
|
+
if line > 0:
|
|
313
|
+
return path, line
|
|
314
|
+
return path, 0
|
|
315
|
+
|
|
316
|
+
candidate_paths = changed_new_paths(refs, changed_path_cache)
|
|
317
|
+
if len(candidate_paths) > MAX_CROSS_FILE_REMAP_PATHS:
|
|
318
|
+
return path, 0
|
|
319
|
+
|
|
320
|
+
matches: list[tuple[str, int]] = []
|
|
321
|
+
for candidate_path in candidate_paths:
|
|
322
|
+
if candidate_path == path:
|
|
323
|
+
continue
|
|
324
|
+
line = unique_existing_code_line(
|
|
325
|
+
refs,
|
|
326
|
+
candidate_path,
|
|
327
|
+
comment,
|
|
328
|
+
diff_line_cache=diff_line_cache,
|
|
329
|
+
file_line_cache=file_line_cache,
|
|
330
|
+
)
|
|
331
|
+
if line > 0:
|
|
332
|
+
matches.append((candidate_path, line))
|
|
333
|
+
if len(matches) > 1:
|
|
334
|
+
break
|
|
335
|
+
|
|
336
|
+
return matches[0] if len(matches) == 1 else (path, 0)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def post_results(config: GitLabConfig, result: dict[str, Any]) -> int:
|
|
340
|
+
"""Post OCR result comments to GitLab and return a process exit code."""
|
|
341
|
+
|
|
342
|
+
if config.current_user_id is None:
|
|
343
|
+
print(
|
|
344
|
+
"Cannot resolve current GitLab user; refusing to publish OCR review notes.",
|
|
345
|
+
file=sys.stderr,
|
|
346
|
+
)
|
|
347
|
+
print_posting_failure_banner()
|
|
348
|
+
return 1
|
|
349
|
+
|
|
350
|
+
comments_value = result.get("comments", [])
|
|
351
|
+
warnings_value = result.get("warnings", [])
|
|
352
|
+
tool_calls_summary = format_tool_calls_summary(result.get("tool_calls"))
|
|
353
|
+
token_usage_summary = format_token_usage_summary(result)
|
|
354
|
+
|
|
355
|
+
if not isinstance(comments_value, list):
|
|
356
|
+
return invalid_ocr_schema_exit(config, "field 'comments' must be a list")
|
|
357
|
+
if not isinstance(warnings_value, list):
|
|
358
|
+
return invalid_ocr_schema_exit(config, "field 'warnings' must be a list")
|
|
359
|
+
|
|
360
|
+
comments: list[dict[str, Any]] = []
|
|
361
|
+
for index, comment in enumerate(comments_value):
|
|
362
|
+
if not isinstance(comment, dict):
|
|
363
|
+
return invalid_ocr_schema_exit(config, f"field 'comments[{index}]' must be an object")
|
|
364
|
+
comments.append(comment)
|
|
365
|
+
|
|
366
|
+
warnings = warnings_value
|
|
367
|
+
billing_warnings = llm_billing_failure_warnings(warnings)
|
|
368
|
+
if billing_warnings:
|
|
369
|
+
print(
|
|
370
|
+
"OCR reported an LLM provider billing/quota failure; refusing to publish normal review notes.",
|
|
371
|
+
file=sys.stderr,
|
|
372
|
+
)
|
|
373
|
+
return post_llm_provider_failure(
|
|
374
|
+
config,
|
|
375
|
+
billing_warnings,
|
|
376
|
+
tool_calls_summary=tool_calls_summary,
|
|
377
|
+
token_usage_summary=token_usage_summary,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
previous_bot_comment_refs = collect_previous_bot_comment_refs(config)
|
|
381
|
+
draft_note_ids: list[int] = []
|
|
382
|
+
if previous_bot_comment_refs is None:
|
|
383
|
+
print(
|
|
384
|
+
"Cannot collect previous OCR bot comments reliably; refusing to publish "
|
|
385
|
+
"new OCR review notes so resolved, /ocr skip, and /ocr keep state is preserved.",
|
|
386
|
+
file=sys.stderr,
|
|
387
|
+
)
|
|
388
|
+
print_posting_failure_banner()
|
|
389
|
+
return 1 if strict_posting() else 0
|
|
390
|
+
|
|
391
|
+
annotate_comment_fingerprints(comments)
|
|
392
|
+
|
|
393
|
+
comments, skipped_count = filter_skipped_comments(comments, previous_bot_comment_refs)
|
|
394
|
+
if skipped_count:
|
|
395
|
+
print(
|
|
396
|
+
f"Skipped {skipped_count} OCR comment(s) at locations resolved or "
|
|
397
|
+
"skipped by the reviewer."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
publishable_comment_count = len(comments)
|
|
401
|
+
publish_limit = max_post_comments()
|
|
402
|
+
omitted_count = max(0, publishable_comment_count - publish_limit)
|
|
403
|
+
if omitted_count:
|
|
404
|
+
print(
|
|
405
|
+
f"Omitting {omitted_count} OCR comment(s) because OCR_MAX_POST_COMMENTS="
|
|
406
|
+
f"{publish_limit}.",
|
|
407
|
+
file=sys.stderr,
|
|
408
|
+
)
|
|
409
|
+
comments = comments[:publish_limit]
|
|
410
|
+
|
|
411
|
+
reviewer_guide = format_reviewer_guide(comments, omitted_count)
|
|
412
|
+
|
|
413
|
+
if publishable_comment_count == 0:
|
|
414
|
+
raw_message = clean_text(result.get("message"))
|
|
415
|
+
if skipped_count:
|
|
416
|
+
skipped_message = (
|
|
417
|
+
f"{skipped_count} OCR comment(s) were suppressed by prior reviewer actions."
|
|
418
|
+
)
|
|
419
|
+
raw_message = f"{raw_message} {skipped_message}" if raw_message else skipped_message
|
|
420
|
+
message = neutralize_quick_actions(
|
|
421
|
+
redact_sensitive(raw_message) or "No review comments generated."
|
|
422
|
+
)
|
|
423
|
+
body = message
|
|
424
|
+
if warnings:
|
|
425
|
+
warning_lines = [
|
|
426
|
+
neutralize_quick_actions(redact_sensitive(clean_text(warning)))
|
|
427
|
+
for warning in warnings
|
|
428
|
+
]
|
|
429
|
+
warning_lines = [warning for warning in warning_lines if warning]
|
|
430
|
+
if warning_lines:
|
|
431
|
+
body += "\n\n**Warnings:**\n" + "\n".join(
|
|
432
|
+
f"- {warning}" for warning in warning_lines
|
|
433
|
+
)
|
|
434
|
+
if tool_calls_summary:
|
|
435
|
+
body += f"\n\n{tool_calls_summary}"
|
|
436
|
+
if token_usage_summary:
|
|
437
|
+
body += f"\n{token_usage_summary}"
|
|
438
|
+
if reviewer_guide:
|
|
439
|
+
body += f"\n\n{reviewer_guide}"
|
|
440
|
+
response = post_review_note_bounded(
|
|
441
|
+
config,
|
|
442
|
+
"**Open Code Review**",
|
|
443
|
+
body,
|
|
444
|
+
draft_note_ids,
|
|
445
|
+
)
|
|
446
|
+
if response is None:
|
|
447
|
+
print("Failed to create OCR no-comments note.", file=sys.stderr)
|
|
448
|
+
return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids)
|
|
449
|
+
if not finalize_posting(config, draft_note_ids):
|
|
450
|
+
return publish_failure_exit(config, draft_note_ids)
|
|
451
|
+
delete_previous_bot_comments_if_collected(config, previous_bot_comment_refs)
|
|
452
|
+
resolve_keep_threads(config, previous_bot_comment_refs)
|
|
453
|
+
return 0
|
|
454
|
+
|
|
455
|
+
refs = get_diff_refs(config)
|
|
456
|
+
inline_count = 0
|
|
457
|
+
failed_comments: list[dict[str, Any]] = []
|
|
458
|
+
fallback_reasons: Counter[str] = Counter()
|
|
459
|
+
diff_line_cache: DiffLineCache = {}
|
|
460
|
+
file_line_cache: FileLineCache = {}
|
|
461
|
+
changed_path_cache: ChangedPathCache = {}
|
|
462
|
+
|
|
463
|
+
for raw_comment in comments:
|
|
464
|
+
if not isinstance(raw_comment, dict):
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
path = clean_text(raw_comment.get("path"))
|
|
468
|
+
old_path = clean_text(raw_comment.get("old_path")) or None
|
|
469
|
+
line = comment_line(raw_comment)
|
|
470
|
+
if refs and line <= 0:
|
|
471
|
+
remapped_path, remapped_line = remap_existing_code_location(
|
|
472
|
+
refs,
|
|
473
|
+
path,
|
|
474
|
+
raw_comment,
|
|
475
|
+
diff_line_cache=diff_line_cache,
|
|
476
|
+
file_line_cache=file_line_cache,
|
|
477
|
+
changed_path_cache=changed_path_cache,
|
|
478
|
+
)
|
|
479
|
+
if remapped_line > 0:
|
|
480
|
+
path = remapped_path
|
|
481
|
+
line = remapped_line
|
|
482
|
+
raw_comment["path"] = remapped_path
|
|
483
|
+
raw_comment["line"] = remapped_line
|
|
484
|
+
old_path = None
|
|
485
|
+
print(
|
|
486
|
+
"Inline posting remapped missing line from existing_code, "
|
|
487
|
+
f"path={path!r}, line={line!r}.",
|
|
488
|
+
file=sys.stderr,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if not refs or not path or line <= 0:
|
|
492
|
+
reason = inline_skip_reason(refs, path, line)
|
|
493
|
+
print(
|
|
494
|
+
f"Inline posting skipped reason={reason}, path={path!r}, line={line!r}; "
|
|
495
|
+
"will publish as fallback.",
|
|
496
|
+
file=sys.stderr,
|
|
497
|
+
)
|
|
498
|
+
fallback_reasons[reason] += 1
|
|
499
|
+
failed_comments.append(raw_comment)
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
inline_result = post_review_discussion(
|
|
503
|
+
config=config,
|
|
504
|
+
path=path,
|
|
505
|
+
line=line,
|
|
506
|
+
body=format_inline_comment(raw_comment),
|
|
507
|
+
refs=refs,
|
|
508
|
+
draft_note_ids=draft_note_ids,
|
|
509
|
+
fingerprint=clean_text(raw_comment.get("_ocr_fingerprint")) or None,
|
|
510
|
+
old_path=old_path,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
if inline_result.posted:
|
|
514
|
+
inline_count += 1
|
|
515
|
+
elif inline_result.invalid_position:
|
|
516
|
+
print(
|
|
517
|
+
f"Inline posting skipped reason=invalid_position, path={path!r}, line={line!r}; "
|
|
518
|
+
"will publish as fallback.",
|
|
519
|
+
file=sys.stderr,
|
|
520
|
+
)
|
|
521
|
+
fallback_reasons["invalid_position"] += 1
|
|
522
|
+
failed_comments.append(raw_comment)
|
|
523
|
+
else:
|
|
524
|
+
print(
|
|
525
|
+
f"Inline posting failed reason=post_failed, path={path!r}, line={line!r}; "
|
|
526
|
+
"refusing fallback because the GitLab write result is ambiguous.",
|
|
527
|
+
file=sys.stderr,
|
|
528
|
+
)
|
|
529
|
+
rollback_current_run_comments(config, previous_bot_comment_refs, draft_note_ids)
|
|
530
|
+
print_posting_failure_banner()
|
|
531
|
+
return 1
|
|
532
|
+
|
|
533
|
+
if failed_comments:
|
|
534
|
+
fallback_chunks = format_fallback_comment_chunks(failed_comments)
|
|
535
|
+
|
|
536
|
+
for index, fallback_chunk in enumerate(fallback_chunks, start=1):
|
|
537
|
+
fallback_title = (
|
|
538
|
+
f"**Open Code Review fallback comments ({index}/{len(fallback_chunks)})**"
|
|
539
|
+
)
|
|
540
|
+
fallback_body = (
|
|
541
|
+
"Open Code Review found issues that could not be posted inline.\n\n"
|
|
542
|
+
f"{fallback_chunk}"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
fallback_response = post_review_note_bounded(
|
|
546
|
+
config,
|
|
547
|
+
fallback_title,
|
|
548
|
+
fallback_body,
|
|
549
|
+
draft_note_ids,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if fallback_response is None:
|
|
553
|
+
print("Failed to create OCR fallback note.", file=sys.stderr)
|
|
554
|
+
return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids)
|
|
555
|
+
|
|
556
|
+
if omitted_count:
|
|
557
|
+
omitted_response = post_review_note_bounded(
|
|
558
|
+
config,
|
|
559
|
+
"**Open Code Review omitted comments**",
|
|
560
|
+
format_omitted_comments_summary(
|
|
561
|
+
publishable_total=publishable_comment_count,
|
|
562
|
+
publish_limit=publish_limit,
|
|
563
|
+
omitted=omitted_count,
|
|
564
|
+
),
|
|
565
|
+
draft_note_ids,
|
|
566
|
+
)
|
|
567
|
+
if omitted_response is None:
|
|
568
|
+
print("Failed to create OCR omitted-comments note.", file=sys.stderr)
|
|
569
|
+
return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids)
|
|
570
|
+
|
|
571
|
+
summary_response = post_review_note_bounded(
|
|
572
|
+
config,
|
|
573
|
+
"**Open Code Review summary**",
|
|
574
|
+
summarize_result(
|
|
575
|
+
total=publishable_comment_count,
|
|
576
|
+
inline_count=inline_count,
|
|
577
|
+
fallback_count=len(failed_comments),
|
|
578
|
+
warning_count=len(warnings),
|
|
579
|
+
comments=comments,
|
|
580
|
+
omitted_count=omitted_count,
|
|
581
|
+
tool_calls_summary=tool_calls_summary,
|
|
582
|
+
token_usage_summary=token_usage_summary,
|
|
583
|
+
reviewer_guide=reviewer_guide,
|
|
584
|
+
fallback_reasons=fallback_reasons,
|
|
585
|
+
reviewed_sha=reviewed_sha(),
|
|
586
|
+
mr_head_sha=mr_head_sha(),
|
|
587
|
+
),
|
|
588
|
+
draft_note_ids,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
if summary_response is None:
|
|
592
|
+
print("Failed to create OCR summary note.", file=sys.stderr)
|
|
593
|
+
return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids)
|
|
594
|
+
|
|
595
|
+
if not finalize_posting(config, draft_note_ids):
|
|
596
|
+
return publish_failure_exit(config, draft_note_ids)
|
|
597
|
+
|
|
598
|
+
delete_previous_bot_comments_if_collected(config, previous_bot_comment_refs)
|
|
599
|
+
resolve_keep_threads(config, previous_bot_comment_refs)
|
|
600
|
+
|
|
601
|
+
print(
|
|
602
|
+
f"Posted OCR comments: mode={post_mode()}, inline={inline_count}, "
|
|
603
|
+
f"fallback={len(failed_comments)}, omitted={omitted_count}, "
|
|
604
|
+
f"total={publishable_comment_count}"
|
|
605
|
+
)
|
|
606
|
+
return 0
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def resolve_keep_threads(config: GitLabConfig, previous_refs: BotCommentRefs | None) -> None:
|
|
610
|
+
"""Resolve threads marked with `/ocr keep` after a successful post."""
|
|
611
|
+
|
|
612
|
+
if previous_refs is None or not previous_refs.threads_to_resolve:
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
resolved = 0
|
|
616
|
+
for discussion_id in previous_refs.threads_to_resolve:
|
|
617
|
+
if resolve_discussion(config, discussion_id):
|
|
618
|
+
resolved += 1
|
|
619
|
+
|
|
620
|
+
if resolved:
|
|
621
|
+
print(f"Resolved {resolved} OCR thread(s) per /ocr keep reply.")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def invalid_ocr_schema_exit(
|
|
625
|
+
config: GitLabConfig,
|
|
626
|
+
message: str,
|
|
627
|
+
intro: str = "OCR produced valid JSON that does not match the expected review schema.",
|
|
628
|
+
title: str = "**Open Code Review result schema error**",
|
|
629
|
+
) -> int:
|
|
630
|
+
"""Post a visible OCR result artifact error without replacing old review notes."""
|
|
631
|
+
|
|
632
|
+
print(f"OCR result schema error: {message}", file=sys.stderr)
|
|
633
|
+
draft_note_ids: list[int] = []
|
|
634
|
+
safe_message = neutralize_quick_actions(clean_text(message))
|
|
635
|
+
response = post_review_note_bounded(
|
|
636
|
+
config,
|
|
637
|
+
title,
|
|
638
|
+
f"{neutralize_quick_actions(clean_text(intro))}\n\n"
|
|
639
|
+
"- Normal review comments were not published.\n"
|
|
640
|
+
"- Previous OCR review comments were preserved.\n"
|
|
641
|
+
f"- Schema error: {inline_code(safe_message)}",
|
|
642
|
+
draft_note_ids,
|
|
643
|
+
)
|
|
644
|
+
if response is None:
|
|
645
|
+
print("Failed to create OCR schema-error note.", file=sys.stderr)
|
|
646
|
+
return posting_failure_exit(config, None, draft_note_ids)
|
|
647
|
+
|
|
648
|
+
if not finalize_posting(config, draft_note_ids):
|
|
649
|
+
return publish_failure_exit(config, draft_note_ids)
|
|
650
|
+
|
|
651
|
+
return 1 if strict_posting() else 0
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def post_llm_provider_failure(
|
|
655
|
+
config: GitLabConfig,
|
|
656
|
+
warnings: Sequence[str],
|
|
657
|
+
tool_calls_summary: str = "",
|
|
658
|
+
token_usage_summary: str = "",
|
|
659
|
+
) -> int:
|
|
660
|
+
"""Post a visible OCR provider failure and preserve previous review notes."""
|
|
661
|
+
|
|
662
|
+
draft_note_ids: list[int] = []
|
|
663
|
+
warning_items: list[str] = []
|
|
664
|
+
for warning in warnings[:10]:
|
|
665
|
+
safe_warning = compact_escaped_text(
|
|
666
|
+
neutralize_quick_actions(redact_sensitive(warning)),
|
|
667
|
+
1200,
|
|
668
|
+
)
|
|
669
|
+
if safe_warning:
|
|
670
|
+
warning_items.append(f"- {safe_warning}")
|
|
671
|
+
|
|
672
|
+
body_parts = [
|
|
673
|
+
"OCR could not complete the review because the LLM provider reported a billing, quota, or balance failure.",
|
|
674
|
+
"",
|
|
675
|
+
"- Normal review comments were not published.",
|
|
676
|
+
"- Previous OCR review comments were preserved.",
|
|
677
|
+
"- Refill or rotate the LLM token, then rerun the pipeline.",
|
|
678
|
+
]
|
|
679
|
+
if warning_items:
|
|
680
|
+
body_parts.extend(["", "**Provider warnings:**", *warning_items])
|
|
681
|
+
if tool_calls_summary:
|
|
682
|
+
body_parts.extend(["", tool_calls_summary])
|
|
683
|
+
if token_usage_summary:
|
|
684
|
+
body_parts.append(token_usage_summary)
|
|
685
|
+
|
|
686
|
+
response = post_review_note_bounded(
|
|
687
|
+
config,
|
|
688
|
+
"**Open Code Review provider failure**",
|
|
689
|
+
"\n".join(body_parts),
|
|
690
|
+
draft_note_ids,
|
|
691
|
+
)
|
|
692
|
+
if response is None:
|
|
693
|
+
print("Failed to create OCR provider-failure note.", file=sys.stderr)
|
|
694
|
+
cleanup_drafts_created_by_this_run(config, draft_note_ids)
|
|
695
|
+
print_posting_failure_banner()
|
|
696
|
+
return 1
|
|
697
|
+
|
|
698
|
+
if not finalize_posting(config, draft_note_ids):
|
|
699
|
+
return publish_failure_exit(config, draft_note_ids)
|
|
700
|
+
|
|
701
|
+
print(
|
|
702
|
+
"Open Code Review did not complete because the LLM provider reported a billing/quota failure.",
|
|
703
|
+
file=sys.stderr,
|
|
704
|
+
)
|
|
705
|
+
return 1
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
STDERR_PROBE_BYTES = 64 * 1024
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def read_stderr_excerpt(stderr_path: Path, max_chars: int = 4000) -> str:
|
|
712
|
+
"""Read a bounded, redacted excerpt of an OCR stderr log.
|
|
713
|
+
|
|
714
|
+
Reads at most STDERR_PROBE_BYTES so a multi-megabyte stderr does
|
|
715
|
+
not stress the runner memory. Redaction runs on the full probe
|
|
716
|
+
BEFORE truncation so a secret bisected by the char limit is still
|
|
717
|
+
matched against its full environment-value form.
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
if not stderr_path.exists():
|
|
721
|
+
return ""
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
with stderr_path.open("rb") as handle:
|
|
725
|
+
chunk = handle.read(STDERR_PROBE_BYTES)
|
|
726
|
+
except OSError:
|
|
727
|
+
return ""
|
|
728
|
+
|
|
729
|
+
text = chunk.decode("utf-8", errors="replace").strip()
|
|
730
|
+
if not text:
|
|
731
|
+
return ""
|
|
732
|
+
return redact_sensitive(text)[:max_chars]
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def post_parse_error(config: GitLabConfig, stderr_path: Path) -> int:
|
|
736
|
+
"""Post a safe error note when OCR output is not valid JSON.
|
|
737
|
+
|
|
738
|
+
Previous OCR bot notes are intentionally NOT cleaned up here: a parse
|
|
739
|
+
error means the new review may be partial or misleading, so the last
|
|
740
|
+
valid review must remain visible until a successful run replaces it.
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
draft_note_ids: list[int] = []
|
|
744
|
+
details_enabled = os.environ.get("OCR_POST_ERROR_DETAILS") == "1"
|
|
745
|
+
details = read_stderr_excerpt(stderr_path) if details_enabled else ""
|
|
746
|
+
|
|
747
|
+
if details:
|
|
748
|
+
parse_error_response = post_review_note_bounded(
|
|
749
|
+
config,
|
|
750
|
+
"**Open Code Review parse error**",
|
|
751
|
+
"**Open Code Review failed to produce valid JSON.**\n\n"
|
|
752
|
+
+ neutralize_quick_actions(markdown_code_block("text", details)),
|
|
753
|
+
draft_note_ids,
|
|
754
|
+
)
|
|
755
|
+
else:
|
|
756
|
+
parse_error_response = post_review_note_bounded(
|
|
757
|
+
config,
|
|
758
|
+
"**Open Code Review parse error**",
|
|
759
|
+
"**Open Code Review failed to produce valid JSON.** Check the CI job log.",
|
|
760
|
+
draft_note_ids,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
if parse_error_response is None:
|
|
764
|
+
print("Failed to create OCR parse-error note.", file=sys.stderr)
|
|
765
|
+
return posting_failure_exit(config, None, draft_note_ids)
|
|
766
|
+
|
|
767
|
+
if not finalize_posting(config, draft_note_ids):
|
|
768
|
+
return publish_failure_exit(config, draft_note_ids)
|
|
769
|
+
|
|
770
|
+
return 1 if strict_posting() else 0
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def post_ocr_failure(config: GitLabConfig, stderr_path: Path, exit_code: int) -> int:
|
|
774
|
+
"""Post a safe failure note when OCR exits with a non-zero status.
|
|
775
|
+
|
|
776
|
+
A non-zero OCR exit code means the JSON output may be partial or misleading,
|
|
777
|
+
so this script intentionally does not publish normal review comments.
|
|
778
|
+
Previous OCR bot notes are intentionally NOT cleaned up here: the last
|
|
779
|
+
valid review must remain visible until a successful run replaces it.
|
|
780
|
+
|
|
781
|
+
A negative `exit_code` is the convention used by `main()` to flag a
|
|
782
|
+
successful OCR run that produced no result artifact at all (so there
|
|
783
|
+
is no real exit code to report); render that as "missing artifact"
|
|
784
|
+
instead of a confusing negative number.
|
|
785
|
+
"""
|
|
786
|
+
|
|
787
|
+
draft_note_ids: list[int] = []
|
|
788
|
+
details_enabled = os.environ.get("OCR_POST_ERROR_DETAILS") == "1"
|
|
789
|
+
details = read_stderr_excerpt(stderr_path) if details_enabled else ""
|
|
790
|
+
|
|
791
|
+
if exit_code < 0:
|
|
792
|
+
exit_code_line = "- OCR result file was missing on disk."
|
|
793
|
+
else:
|
|
794
|
+
exit_code_line = f"- OCR exit code: `{exit_code}`"
|
|
795
|
+
|
|
796
|
+
body = (
|
|
797
|
+
f"**Open Code Review did not complete successfully.**\n\n"
|
|
798
|
+
f"{exit_code_line}\n"
|
|
799
|
+
"- Normal review comments were not published because the result may be partial or misleading.\n"
|
|
800
|
+
"- Previous OCR review comments were preserved."
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
if details:
|
|
804
|
+
body += "\n\n" + neutralize_quick_actions(markdown_code_block("text", details))
|
|
805
|
+
|
|
806
|
+
response = post_review_note_bounded(
|
|
807
|
+
config,
|
|
808
|
+
"**Open Code Review failure**",
|
|
809
|
+
body,
|
|
810
|
+
draft_note_ids,
|
|
811
|
+
)
|
|
812
|
+
if response is None:
|
|
813
|
+
print("Failed to create OCR failure note.", file=sys.stderr)
|
|
814
|
+
return posting_failure_exit(config, None, draft_note_ids)
|
|
815
|
+
|
|
816
|
+
if not finalize_posting(config, draft_note_ids):
|
|
817
|
+
return publish_failure_exit(config, draft_note_ids)
|
|
818
|
+
|
|
819
|
+
return 1 if strict_posting() else 0
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def main(argv: list[str] | None = None) -> int:
|
|
823
|
+
"""CLI entrypoint."""
|
|
824
|
+
|
|
825
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
826
|
+
result_path = Path(args[0]) if len(args) >= 1 else Path("/tmp/ocr-result.json")
|
|
827
|
+
stderr_path = Path(args[1]) if len(args) >= 2 else Path("/tmp/ocr-stderr.log")
|
|
828
|
+
|
|
829
|
+
config = load_gitlab_config()
|
|
830
|
+
if config is None:
|
|
831
|
+
print(
|
|
832
|
+
"GitLab posting configuration is missing or invalid; refusing to report success.",
|
|
833
|
+
file=sys.stderr,
|
|
834
|
+
)
|
|
835
|
+
return 1
|
|
836
|
+
if config.current_user_id is None:
|
|
837
|
+
print(
|
|
838
|
+
"Cannot resolve current GitLab user; refusing to publish any OCR notes.",
|
|
839
|
+
file=sys.stderr,
|
|
840
|
+
)
|
|
841
|
+
print_posting_failure_banner()
|
|
842
|
+
return 1
|
|
843
|
+
|
|
844
|
+
exit_code = ocr_exit_code()
|
|
845
|
+
if exit_code != 0:
|
|
846
|
+
return post_ocr_failure(config, stderr_path, exit_code)
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
result = load_ocr_result(result_path)
|
|
850
|
+
except OcrResultMissing as exc:
|
|
851
|
+
# OCR exited 0 but did not produce the result file at all —
|
|
852
|
+
# treat as a failed run, not a parse error. The note text
|
|
853
|
+
# comes from post_ocr_failure with a synthetic exit code so
|
|
854
|
+
# the reviewer sees "did not complete successfully".
|
|
855
|
+
print(f"OCR result file missing or unreadable: {exc}", file=sys.stderr)
|
|
856
|
+
return post_ocr_failure(config, stderr_path, exit_code=-1)
|
|
857
|
+
except OcrResultTooLarge as exc:
|
|
858
|
+
print(f"OCR result file is too large: {exc}", file=sys.stderr)
|
|
859
|
+
return invalid_ocr_schema_exit(
|
|
860
|
+
config,
|
|
861
|
+
str(exc),
|
|
862
|
+
intro="OCR result artifact exceeded the configured safety limit before it could be parsed.",
|
|
863
|
+
title="**Open Code Review result artifact error**",
|
|
864
|
+
)
|
|
865
|
+
except OcrResultMalformed as exc:
|
|
866
|
+
print(f"Cannot parse OCR result JSON: {exc}", file=sys.stderr)
|
|
867
|
+
return post_parse_error(config, stderr_path)
|
|
868
|
+
|
|
869
|
+
if not isinstance(result, dict):
|
|
870
|
+
print("OCR result JSON must be an object.", file=sys.stderr)
|
|
871
|
+
return invalid_ocr_schema_exit(config, "top-level JSON value must be an object")
|
|
872
|
+
|
|
873
|
+
return post_results(config, result)
|