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,181 @@
1
+ """Environment-derived settings and limits for OCR posting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ from functools import cache
9
+
10
+ SUGGESTION_HEADER = "**Suggestion:**"
11
+
12
+
13
+ SUGGESTION_BLOCK_RE = re.compile(r"\n\n" + re.escape(SUGGESTION_HEADER) + r"\n`{3,}suggestion[:\n]")
14
+
15
+
16
+ DEFAULT_HTTP_TIMEOUT_SECONDS = 25
17
+
18
+
19
+ MAX_NOTE_CHARS = 55_000
20
+
21
+
22
+ MAX_INLINE_NOTE_CHARS = 20_000
23
+
24
+
25
+ MAX_API_ERROR_BODY_BYTES = 64 * 1024
26
+
27
+
28
+ MAX_API_RESPONSE_BODY_BYTES = MAX_NOTE_CHARS * 120
29
+
30
+
31
+ MAX_SUGGESTION_CODE_CHARS = 12_000
32
+
33
+
34
+ MAX_FALLBACK_CODE_DETAILS_CHARS = 20_000
35
+
36
+
37
+ FALLBACK_NOTE_CHUNK_BUDGET = MAX_NOTE_CHARS - 1_000
38
+
39
+
40
+ MAX_SUGGESTION_SPAN_LINES = 200
41
+
42
+
43
+ DEFAULT_MAX_POST_COMMENTS = 50
44
+
45
+
46
+ MAX_POST_COMMENTS_HARD_LIMIT = 200
47
+
48
+
49
+ DEFAULT_MAX_RESULT_BYTES = 2_000_000
50
+
51
+
52
+ MAX_RESULT_BYTES_HARD_LIMIT = 20_000_000
53
+
54
+
55
+ MAX_TOOL_CALL_SUMMARY_TOOLS = 6
56
+
57
+
58
+ MAX_TOOL_CALL_NAME_CHARS = 48
59
+
60
+
61
+ MAX_REVIEWER_GUIDE_COMMENTS = 6
62
+
63
+
64
+ MAX_REVIEWER_GUIDE_LABEL_CHARS = 80
65
+
66
+
67
+ MAX_REVIEWER_GUIDE_LOCATION_CHARS = 180
68
+
69
+
70
+ MAX_REVIEWER_GUIDE_TEXT_CHARS = 360
71
+
72
+
73
+ TRUNCATION_NOTICE = (
74
+ "Output was truncated because the fallback note exceeded the safe GitLab note size. "
75
+ "Check the CI logs or OCR output artifact for the complete result."
76
+ )
77
+
78
+
79
+ def getenv(name: str, default: str = "") -> str:
80
+ """Return an environment variable or a default string."""
81
+
82
+ return os.environ.get(name, default)
83
+
84
+
85
+ @cache
86
+ def post_mode() -> str:
87
+ """Return posting mode.
88
+
89
+ Defaults to draft mode. The old direct mode is retained as an emergency
90
+ override via OCR_POST_MODE=direct, but no CI variable is required.
91
+ """
92
+
93
+ raw_mode = getenv("OCR_POST_MODE", "draft").strip()
94
+ mode = raw_mode.lower()
95
+ if mode in {"draft", "direct"}:
96
+ return mode
97
+ print("Invalid OCR_POST_MODE value; using draft mode.", file=sys.stderr)
98
+ return "draft"
99
+
100
+
101
+ def strict_posting() -> bool:
102
+ """Return whether posting failures should make this script fail."""
103
+
104
+ return getenv("OCR_STRICT_POSTING", "false").strip().lower() in {"1", "true", "yes"}
105
+
106
+
107
+ def max_post_comments() -> int:
108
+ """Return the maximum number of OCR comments to publish individually."""
109
+
110
+ raw = getenv("OCR_MAX_POST_COMMENTS", str(DEFAULT_MAX_POST_COMMENTS)).strip()
111
+ try:
112
+ parsed = int(raw)
113
+ except ValueError:
114
+ print(
115
+ f"OCR_MAX_POST_COMMENTS is not an integer; using default {DEFAULT_MAX_POST_COMMENTS}.",
116
+ file=sys.stderr,
117
+ )
118
+ return DEFAULT_MAX_POST_COMMENTS
119
+
120
+ if parsed < 0:
121
+ print(
122
+ "OCR_MAX_POST_COMMENTS must be zero or greater; "
123
+ f"using default {DEFAULT_MAX_POST_COMMENTS}.",
124
+ file=sys.stderr,
125
+ )
126
+ return DEFAULT_MAX_POST_COMMENTS
127
+
128
+ if parsed > MAX_POST_COMMENTS_HARD_LIMIT:
129
+ print(
130
+ f"OCR_MAX_POST_COMMENTS exceeds hard limit {MAX_POST_COMMENTS_HARD_LIMIT}; "
131
+ f"using {MAX_POST_COMMENTS_HARD_LIMIT}.",
132
+ file=sys.stderr,
133
+ )
134
+ return MAX_POST_COMMENTS_HARD_LIMIT
135
+
136
+ return parsed
137
+
138
+
139
+ def max_result_bytes() -> int:
140
+ """Return the maximum OCR JSON artifact size to read into memory."""
141
+
142
+ raw = getenv("OCR_MAX_RESULT_BYTES", str(DEFAULT_MAX_RESULT_BYTES)).strip()
143
+ try:
144
+ parsed = int(raw)
145
+ except ValueError:
146
+ print(
147
+ f"OCR_MAX_RESULT_BYTES is not an integer; using default {DEFAULT_MAX_RESULT_BYTES}.",
148
+ file=sys.stderr,
149
+ )
150
+ return DEFAULT_MAX_RESULT_BYTES
151
+
152
+ if parsed <= 0:
153
+ print(
154
+ f"OCR_MAX_RESULT_BYTES must be positive; using default {DEFAULT_MAX_RESULT_BYTES}.",
155
+ file=sys.stderr,
156
+ )
157
+ return DEFAULT_MAX_RESULT_BYTES
158
+
159
+ if parsed > MAX_RESULT_BYTES_HARD_LIMIT:
160
+ print(
161
+ f"OCR_MAX_RESULT_BYTES exceeds hard limit {MAX_RESULT_BYTES_HARD_LIMIT}; "
162
+ f"using {MAX_RESULT_BYTES_HARD_LIMIT}.",
163
+ file=sys.stderr,
164
+ )
165
+ return MAX_RESULT_BYTES_HARD_LIMIT
166
+
167
+ return parsed
168
+
169
+
170
+ def ocr_exit_code() -> int:
171
+ """Return OCR process exit code passed by CI."""
172
+
173
+ raw = getenv("OCR_EXIT_CODE", "0").strip()
174
+ try:
175
+ return int(raw)
176
+ except ValueError:
177
+ print(
178
+ "OCR_EXIT_CODE is not an integer; treating as failure (1).",
179
+ file=sys.stderr,
180
+ )
181
+ return 1
@@ -0,0 +1,468 @@
1
+ """Previous OCR note snapshot, suppression, cleanup, and rollback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from ocr_toolkit.posting.comments import comment_line, line_number
11
+ from ocr_toolkit.posting.gitlab import (
12
+ GitLabConfig,
13
+ api_get_paginated,
14
+ delete_discussion_note,
15
+ delete_draft_note,
16
+ delete_plain_note,
17
+ draft_note_id,
18
+ )
19
+ from ocr_toolkit.posting.markers import (
20
+ OCR_REPLY_COMMAND_RE,
21
+ author_id_from_note,
22
+ comment_fingerprint,
23
+ comment_fingerprint_candidates,
24
+ fingerprint_from_marker,
25
+ is_diff_note,
26
+ is_own_bot_note,
27
+ )
28
+ from ocr_toolkit.posting.settings import post_mode, strict_posting
29
+
30
+
31
+ @dataclass
32
+ class BotCommentRefs:
33
+ """References to previous bot-created notes that can be deleted after success."""
34
+
35
+ plain_note_ids: list[int] = field(default_factory=list)
36
+ discussion_note_refs: list[tuple[str, int]] = field(default_factory=list)
37
+ draft_note_ids: list[int] = field(default_factory=list)
38
+ # Complete pre-run id baseline, including human-touched discussions that
39
+ # success cleanup must preserve. Rollback subtracts against these fields so
40
+ # it deletes only current-run notes after a failed publish attempt.
41
+ all_plain_note_ids: list[int] = field(default_factory=list)
42
+ all_discussion_note_refs: list[tuple[str, int]] = field(default_factory=list)
43
+ all_draft_note_ids: list[int] = field(default_factory=list)
44
+ # (path, new_line) pairs the reviewer marked as resolved or asked to
45
+ # skip via a reply command. New OCR comments hitting these locations
46
+ # are dropped before posting.
47
+ skip_inline_keys: set[tuple[str, int]] = field(default_factory=set)
48
+ # Fingerprints (see `comment_fingerprint`) from past resolved or
49
+ # skip-flagged comments. New comments with a matching fingerprint are
50
+ # dropped regardless of line number, so the skip survives line shifts.
51
+ skip_fingerprints: set[str] = field(default_factory=set)
52
+ # Threads that the bot should resolve programmatically after the
53
+ # current run finishes (used by /ocr keep replies).
54
+ threads_to_resolve: list[str] = field(default_factory=list)
55
+
56
+
57
+ def cleanup_drafts_created_by_this_run(config: GitLabConfig, draft_note_ids: list[int]) -> None:
58
+ """Delete draft notes created by this script run after a creation failure."""
59
+
60
+ for note_id in draft_note_ids:
61
+ delete_draft_note(config, note_id)
62
+
63
+
64
+ def filter_skipped_comments(
65
+ comments: Sequence[dict[str, Any]],
66
+ previous_refs: BotCommentRefs | None,
67
+ ) -> tuple[list[dict[str, Any]], int]:
68
+ """Drop OCR comments the reviewer asked to ignore."""
69
+
70
+ if previous_refs is None:
71
+ return list(comments), 0
72
+
73
+ skip_keys = previous_refs.skip_inline_keys
74
+ skip_fingerprints = previous_refs.skip_fingerprints
75
+ if not skip_keys and not skip_fingerprints:
76
+ return list(comments), 0
77
+
78
+ kept: list[dict[str, Any]] = []
79
+ dropped = 0
80
+ consumed_base_fingerprints: set[str] = set()
81
+ for comment in comments:
82
+ path = str(comment.get("path") or "").strip()
83
+ line = comment_line(comment)
84
+ if path and line > 0 and (path, line) in skip_keys:
85
+ dropped += 1
86
+ continue
87
+ if skip_fingerprints:
88
+ fingerprints = comment_fingerprint_candidates(comment)
89
+ matched = fingerprints.intersection(skip_fingerprints)
90
+ base_fingerprint = comment_fingerprint(comment)
91
+ if base_fingerprint in matched:
92
+ if base_fingerprint in consumed_base_fingerprints:
93
+ matched.remove(base_fingerprint)
94
+ else:
95
+ consumed_base_fingerprints.add(base_fingerprint)
96
+ if matched:
97
+ dropped += 1
98
+ continue
99
+ kept.append(comment)
100
+
101
+ return kept, dropped
102
+
103
+
104
+ def discussion_inline_key(discussion: dict[str, Any]) -> tuple[str, int] | None:
105
+ """Return (path, new_line) for an inline discussion, if available.
106
+
107
+ GitLab inline discussions store the diff position on the first note.
108
+ For non-inline (general) discussions the position is missing — those
109
+ are not tracked in the skip-set because they have no anchor line.
110
+ """
111
+
112
+ notes = discussion.get("notes")
113
+ if not isinstance(notes, list) or not notes:
114
+ return None
115
+
116
+ first = notes[0]
117
+ if not isinstance(first, dict):
118
+ return None
119
+
120
+ position = first.get("position")
121
+ if not isinstance(position, dict):
122
+ return None
123
+
124
+ path = position.get("new_path") or position.get("old_path")
125
+ line = line_number(position.get("new_line"))
126
+ if not isinstance(path, str) or not path or line <= 0:
127
+ return None
128
+ return (path, line)
129
+
130
+
131
+ def reviewer_command_in_thread(config: GitLabConfig, notes: Sequence[Any]) -> str | None:
132
+ """Return the last reviewer-issued `/ocr (skip|keep)` command, or None.
133
+
134
+ Looks only at notes whose author is NOT the bot, so the bot's own
135
+ follow-ups cannot trigger a skip. The newest matching command wins.
136
+ """
137
+
138
+ bot_user_id = config.current_user_id
139
+ found: str | None = None
140
+
141
+ for note in notes:
142
+ if not isinstance(note, dict):
143
+ continue
144
+
145
+ author_id = author_id_from_note(note)
146
+ if bot_user_id is not None and author_id == bot_user_id:
147
+ continue
148
+ if note.get("system"):
149
+ continue
150
+
151
+ body = str(note.get("body") or "")
152
+ for match in OCR_REPLY_COMMAND_RE.finditer(body):
153
+ found = match.group(1).lower()
154
+
155
+ return found
156
+
157
+
158
+ def process_discussion_for_refs(
159
+ config: GitLabConfig,
160
+ refs: BotCommentRefs,
161
+ discussion_id: str,
162
+ notes: Sequence[Any],
163
+ *,
164
+ preserve_human_touched: bool = True,
165
+ ) -> None:
166
+ """Classify a discussion into delete/skip/keep buckets.
167
+
168
+ Behavior:
169
+ - resolved threads: pull their inline key + bot fingerprints into the
170
+ skip-set and keep them on the MR (do NOT add to discussion_note_refs);
171
+ - unresolved threads with a reviewer `/ocr skip` reply: same as
172
+ resolved — skip new comments at that location, keep the thread;
173
+ - unresolved threads with a `/ocr keep` reply: schedule programmatic
174
+ resolution after posting, and skip new comments there;
175
+ - unresolved threads with any human reply: preserve the full discussion
176
+ and suppress duplicates, because reviewer-visible context now belongs
177
+ to that conversation even without an explicit `/ocr` command;
178
+ - everything else (plain unresolved bot threads): mark for deletion
179
+ as before, so a fresh review replaces the previous one.
180
+ """
181
+
182
+ bot_notes: list[dict[str, Any]] = []
183
+ fingerprints: list[str] = []
184
+ is_resolved = False
185
+ human_reply = False
186
+ bot_user_id = config.current_user_id
187
+
188
+ for note in notes:
189
+ if not isinstance(note, dict):
190
+ continue
191
+
192
+ if note.get("resolved"):
193
+ is_resolved = True
194
+
195
+ if not note.get("system"):
196
+ author_id = author_id_from_note(note)
197
+ if bot_user_id is None or author_id != bot_user_id:
198
+ human_reply = True
199
+
200
+ if not is_own_bot_note(config, note, body_field="body"):
201
+ continue
202
+
203
+ bot_notes.append(note)
204
+ fingerprint = fingerprint_from_marker(str(note.get("body") or ""))
205
+ if fingerprint:
206
+ fingerprints.append(fingerprint)
207
+
208
+ if not bot_notes:
209
+ return
210
+
211
+ reviewer_command = reviewer_command_in_thread(config, notes)
212
+ inline_key = None
213
+ first_note = bot_notes[0]
214
+ if isinstance(first_note, dict):
215
+ # discussion_inline_key needs the discussion-level shape, so
216
+ # synthesize one with the bot note as the leading element.
217
+ inline_key = discussion_inline_key({"notes": [first_note]})
218
+
219
+ if preserve_human_touched and (
220
+ is_resolved or reviewer_command in {"skip", "keep"} or human_reply
221
+ ):
222
+ if inline_key is not None:
223
+ refs.skip_inline_keys.add(inline_key)
224
+ refs.skip_fingerprints.update(fingerprints)
225
+ if reviewer_command == "keep" and not is_resolved:
226
+ refs.threads_to_resolve.append(discussion_id)
227
+ return
228
+
229
+ for note in bot_notes:
230
+ note_id = note.get("id")
231
+ if isinstance(note_id, int):
232
+ refs.discussion_note_refs.append((discussion_id, note_id))
233
+
234
+
235
+ def collect_previous_bot_comment_refs(
236
+ config: GitLabConfig, *, preserve_human_touched: bool = True
237
+ ) -> BotCommentRefs | None:
238
+ """Collect previous OCR bot notes without deleting them.
239
+
240
+ Old bot notes are deleted only after the new OCR review is successfully
241
+ created and published. This prevents a failed rerun from erasing the last
242
+ valid review.
243
+ """
244
+
245
+ refs = BotCommentRefs()
246
+ discussion_bot_note_ids: set[int] = set()
247
+
248
+ plain_notes = api_get_paginated(config, "/notes?sort=desc&order_by=created_at", max_pages=50)
249
+ if plain_notes is None:
250
+ return None
251
+
252
+ discussions = api_get_paginated(config, "/discussions", max_pages=50)
253
+ if discussions is None:
254
+ return None
255
+
256
+ for discussion in discussions:
257
+ if not isinstance(discussion, dict):
258
+ continue
259
+
260
+ discussion_id = str(discussion.get("id") or "")
261
+ notes = discussion.get("notes")
262
+
263
+ if not discussion_id or not isinstance(notes, list):
264
+ continue
265
+
266
+ for note in notes:
267
+ if not isinstance(note, dict):
268
+ continue
269
+ if not is_own_bot_note(config, note, body_field="body"):
270
+ continue
271
+ note_id = note.get("id")
272
+ if isinstance(note_id, int):
273
+ discussion_bot_note_ids.add(note_id)
274
+ refs.all_discussion_note_refs.append((discussion_id, note_id))
275
+
276
+ process_discussion_for_refs(
277
+ config,
278
+ refs,
279
+ discussion_id,
280
+ notes,
281
+ preserve_human_touched=preserve_human_touched,
282
+ )
283
+
284
+ for note in plain_notes:
285
+ if not isinstance(note, dict):
286
+ continue
287
+
288
+ if not is_own_bot_note(config, note, body_field="body"):
289
+ continue
290
+
291
+ note_id = note.get("id")
292
+ if not isinstance(note_id, int):
293
+ continue
294
+
295
+ # GET /notes can include diff notes that also appear in
296
+ # /discussions. GitLab does not always expose enough shape in
297
+ # /notes to classify them with is_diff_note(), so skip every own
298
+ # bot note id already seen in /discussions before using
299
+ # DELETE /notes/{id}.
300
+ if note_id in discussion_bot_note_ids or is_diff_note(note):
301
+ continue
302
+
303
+ refs.all_plain_note_ids.append(note_id)
304
+ refs.plain_note_ids.append(note_id)
305
+
306
+ draft_notes: list[Any] = []
307
+ if post_mode() == "draft":
308
+ fetched_draft_notes = api_get_paginated(config, "/draft_notes", max_pages=50)
309
+ if fetched_draft_notes is None:
310
+ return None
311
+ draft_notes = fetched_draft_notes
312
+
313
+ for note in draft_notes:
314
+ if not isinstance(note, dict):
315
+ continue
316
+
317
+ if not is_own_bot_note(config, note, body_field="note"):
318
+ continue
319
+
320
+ note_id = draft_note_id(note)
321
+ if note_id is not None:
322
+ refs.all_draft_note_ids.append(note_id)
323
+ refs.draft_note_ids.append(note_id)
324
+
325
+ return refs
326
+
327
+
328
+ def delete_collected_bot_comments(config: GitLabConfig, refs: BotCommentRefs) -> None:
329
+ """Delete collected OCR bot notes."""
330
+
331
+ deleted = 0
332
+
333
+ for note_id in refs.plain_note_ids:
334
+ if delete_plain_note(config, note_id):
335
+ deleted += 1
336
+
337
+ for discussion_id, note_id in refs.discussion_note_refs:
338
+ if delete_discussion_note(config, discussion_id, note_id):
339
+ deleted += 1
340
+
341
+ for note_id in refs.draft_note_ids:
342
+ if delete_draft_note(config, note_id):
343
+ deleted += 1
344
+
345
+ if deleted:
346
+ print(f"Deleted {deleted} OCR bot note(s).")
347
+
348
+
349
+ def delete_previous_bot_comments_if_collected(
350
+ config: GitLabConfig,
351
+ refs: BotCommentRefs | None,
352
+ ) -> None:
353
+ """Delete previous OCR bot notes only when the pre-posting snapshot is reliable."""
354
+
355
+ if refs is None:
356
+ print(
357
+ "Skipping previous OCR bot note cleanup because previous comments were not collected safely.",
358
+ file=sys.stderr,
359
+ )
360
+ return
361
+
362
+ delete_collected_bot_comments(config, refs)
363
+
364
+
365
+ def subtract_bot_comment_ids(current: BotCommentRefs, baseline: BotCommentRefs) -> BotCommentRefs:
366
+ """Return the delete-able id deltas between current and baseline refs.
367
+
368
+ Only the three id-bearing fields (plain notes, inline discussion
369
+ notes, draft notes) are subtracted. Skip/keep state from the
370
+ baseline is intentionally NOT copied because the only caller
371
+ (`rollback_current_run_comments`) consumes the result to delete
372
+ ids only. If a future caller needs skip/keep state, fetch the
373
+ baseline separately rather than using this delta.
374
+ """
375
+
376
+ baseline_plain_note_ids = set(baseline.all_plain_note_ids or baseline.plain_note_ids)
377
+ baseline_discussion_note_refs = set(
378
+ baseline.all_discussion_note_refs or baseline.discussion_note_refs
379
+ )
380
+ baseline_draft_note_ids = set(baseline.all_draft_note_ids or baseline.draft_note_ids)
381
+
382
+ return BotCommentRefs(
383
+ plain_note_ids=[
384
+ note_id for note_id in current.plain_note_ids if note_id not in baseline_plain_note_ids
385
+ ],
386
+ discussion_note_refs=[
387
+ ref for ref in current.discussion_note_refs if ref not in baseline_discussion_note_refs
388
+ ],
389
+ draft_note_ids=[
390
+ note_id for note_id in current.draft_note_ids if note_id not in baseline_draft_note_ids
391
+ ],
392
+ )
393
+
394
+
395
+ def rollback_current_run_comments(
396
+ config: GitLabConfig,
397
+ previous_bot_comment_refs: BotCommentRefs | None,
398
+ draft_note_ids: list[int],
399
+ ) -> None:
400
+ """Best-effort cleanup for comments created during a failed posting attempt."""
401
+
402
+ cleanup_drafts_created_by_this_run(config, draft_note_ids)
403
+
404
+ if previous_bot_comment_refs is None:
405
+ print(
406
+ "Skipping OCR published-note rollback because previous bot comments were not collected safely.",
407
+ file=sys.stderr,
408
+ )
409
+ return
410
+
411
+ current_bot_comment_refs = collect_previous_bot_comment_refs(
412
+ config, preserve_human_touched=False
413
+ )
414
+ if current_bot_comment_refs is None:
415
+ print(
416
+ "Skipping OCR published-note rollback because current bot comments are unavailable.",
417
+ file=sys.stderr,
418
+ )
419
+ return
420
+
421
+ current_run_refs = subtract_bot_comment_ids(current_bot_comment_refs, previous_bot_comment_refs)
422
+ delete_collected_bot_comments(config, current_run_refs)
423
+
424
+
425
+ def print_posting_failure_banner() -> None:
426
+ """Print a visible banner so non-strict posting failures stay noticeable."""
427
+
428
+ banner = (
429
+ "==================================================================\n"
430
+ " OCR POSTING FAILED. Review comments may be missing from the MR.\n"
431
+ " Check the GitLab token, /user reachability, and rate limits.\n"
432
+ " Some unsafe states fail the posting script even when\n"
433
+ " OCR_STRICT_POSTING=false; otherwise set OCR_STRICT_POSTING=true\n"
434
+ " to fail CI on all posting errors.\n"
435
+ "=================================================================="
436
+ )
437
+ print(banner, file=sys.stderr)
438
+
439
+
440
+ def posting_failure_exit(
441
+ config: GitLabConfig,
442
+ previous_bot_comment_refs: BotCommentRefs | None,
443
+ draft_note_ids: list[int],
444
+ ) -> int:
445
+ """Rollback current-run GitLab comments and return the configured failure code."""
446
+
447
+ rollback_current_run_comments(config, previous_bot_comment_refs, draft_note_ids)
448
+ print_posting_failure_banner()
449
+ return 1 if strict_posting() else 0
450
+
451
+
452
+ def publish_failure_exit(config: GitLabConfig, draft_note_ids: list[int]) -> int:
453
+ """Handle ambiguous draft publish failures without deleting published notes.
454
+
455
+ A draft publish timeout/5xx can mean GitLab already made part of this run
456
+ visible. Treat that mixed state as CI-visible even in non-strict mode so an
457
+ operator reruns the job instead of silently accepting two overlapping OCR
458
+ reviews on the MR.
459
+ """
460
+
461
+ cleanup_drafts_created_by_this_run(config, draft_note_ids)
462
+ print(
463
+ "Skipping OCR published-note rollback after draft publish failure; "
464
+ "some draft notes may already be visible on the MR.",
465
+ file=sys.stderr,
466
+ )
467
+ print_posting_failure_banner()
468
+ return 1