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,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)