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,853 @@
1
+ """GitLab API access for OCR MR posting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from ocr_toolkit.common.redaction import redact_sensitive
16
+ from ocr_toolkit.posting.payloads import (
17
+ build_marked_note_body,
18
+ note_body_budget,
19
+ truncate_note_body,
20
+ truncate_plain_text,
21
+ )
22
+ from ocr_toolkit.posting.settings import (
23
+ DEFAULT_HTTP_TIMEOUT_SECONDS,
24
+ MAX_API_ERROR_BODY_BYTES,
25
+ MAX_API_RESPONSE_BODY_BYTES,
26
+ MAX_INLINE_NOTE_CHARS,
27
+ MAX_NOTE_CHARS,
28
+ getenv,
29
+ post_mode,
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class GitLabConfig:
35
+ """GitLab API configuration derived from CI environment variables."""
36
+
37
+ server_url: str
38
+ project_id: str
39
+ merge_request_iid: str
40
+ api_token: str
41
+ auth_header: str
42
+ current_user_id: int | None
43
+
44
+ @property
45
+ def api_base(self) -> str:
46
+ """Return the GitLab Merge Request API base URL."""
47
+
48
+ encoded_project = urllib.parse.quote_plus(self.project_id)
49
+ return (
50
+ f"{self.server_url}/api/v4/projects/{encoded_project}"
51
+ f"/merge_requests/{self.merge_request_iid}"
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class GitLabWriteResult:
57
+ """Detailed result for non-idempotent GitLab write calls."""
58
+
59
+ status: str
60
+ response: Any | None = None
61
+
62
+ @property
63
+ def posted(self) -> bool:
64
+ return self.status == "posted"
65
+
66
+ @property
67
+ def invalid_position(self) -> bool:
68
+ return self.status == "invalid_position"
69
+
70
+ @property
71
+ def write_failed(self) -> bool:
72
+ return self.status == "write_failed"
73
+
74
+
75
+ class GitLabResponseTooLarge(Exception):
76
+ """GitLab response exceeded the bounded read budget."""
77
+
78
+
79
+ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
80
+ """Allow HTTPS redirects only within the configured GitLab origin."""
81
+
82
+ def redirect_request(
83
+ self,
84
+ request: urllib.request.Request,
85
+ file_pointer: Any,
86
+ code: int,
87
+ message: str,
88
+ headers: Any,
89
+ new_url: str,
90
+ ) -> urllib.request.Request | None:
91
+ if request.get_method() not in {"GET", "HEAD"}:
92
+ return None
93
+ redirected = super().redirect_request(
94
+ request, file_pointer, code, message, headers, new_url
95
+ )
96
+ if redirected is None:
97
+ return None
98
+ try:
99
+ original = urllib.parse.urlsplit(request.full_url)
100
+ destination = urllib.parse.urlsplit(redirected.full_url)
101
+ original_port = original.port or 443
102
+ destination_port = destination.port or 443
103
+ except ValueError:
104
+ return None
105
+ if (
106
+ original.scheme.lower() != "https"
107
+ or destination.scheme.lower() != "https"
108
+ or original.hostname != destination.hostname
109
+ or destination.username is not None
110
+ or destination.password is not None
111
+ or original_port != destination_port
112
+ ):
113
+ return None
114
+ return redirected
115
+
116
+
117
+ def _open_gitlab_request(request: urllib.request.Request) -> Any:
118
+ """Open one GitLab request with redirect handling disabled."""
119
+
120
+ return urllib.request.build_opener(_SameOriginRedirectHandler()).open(
121
+ request, timeout=DEFAULT_HTTP_TIMEOUT_SECONDS
122
+ )
123
+
124
+
125
+ def _read_limited_response(response: Any) -> bytes:
126
+ """Read a GitLab response body and fail when it exceeds the success budget."""
127
+
128
+ body = response.read(MAX_API_RESPONSE_BODY_BYTES)
129
+ if len(body) >= MAX_API_RESPONSE_BODY_BYTES and response.read(1):
130
+ raise GitLabResponseTooLarge(f"GitLab response exceeds {MAX_API_RESPONSE_BODY_BYTES} bytes")
131
+ return body
132
+
133
+
134
+ def _retry_after_seconds(header_value: str | None) -> float | None:
135
+ """Parse a `Retry-After` header: integer seconds or an HTTP-date."""
136
+
137
+ if not header_value:
138
+ return None
139
+ header_value = header_value.strip()
140
+ if not header_value:
141
+ return None
142
+ try:
143
+ return max(0.0, float(header_value))
144
+ except ValueError:
145
+ pass
146
+ try:
147
+ # email.utils handles RFC 7231 IMF-fixdate without depending on
148
+ # locale or pulling in third-party libraries.
149
+ from datetime import datetime, timezone
150
+ from email.utils import parsedate_to_datetime
151
+
152
+ when = parsedate_to_datetime(header_value)
153
+ delta = (when - datetime.now(timezone.utc)).total_seconds()
154
+ return max(0.0, delta)
155
+ except (TypeError, ValueError):
156
+ return None
157
+
158
+
159
+ def api_request_url(
160
+ url: str,
161
+ api_token: str,
162
+ auth_header: str,
163
+ data: dict[str, Any] | None = None,
164
+ method: str = "GET",
165
+ ) -> Any | None:
166
+ """Call a GitLab API URL and return parsed JSON, `{}` for empty success, or None."""
167
+
168
+ headers = {
169
+ auth_header: api_token,
170
+ "Content-Type": "application/json",
171
+ }
172
+
173
+ body = json.dumps(data).encode("utf-8") if data is not None else None
174
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
175
+
176
+ retryable_statuses = {429, 500, 502, 503, 504}
177
+ # Only GET is retried. GitLab note writes do not expose an idempotency
178
+ # key here: retrying create, publish, update/resolve, or delete after a
179
+ # lost response can duplicate notes or treat an already-completed write
180
+ # as a failure and trigger destructive cleanup.
181
+ max_attempts = 3 if method.upper() == "GET" else 1
182
+ last_error: Exception | None = None
183
+
184
+ for attempt in range(max_attempts):
185
+ retry_after: float | None = None
186
+ try:
187
+ with _open_gitlab_request(request) as response:
188
+ raw = _read_limited_response(response).decode("utf-8", errors="replace")
189
+ if not raw:
190
+ return {}
191
+ return json.loads(raw)
192
+ except urllib.error.HTTPError as exc:
193
+ message = truncate_plain_text(
194
+ redact_sensitive(
195
+ exc.read(MAX_API_ERROR_BODY_BYTES).decode("utf-8", errors="replace")
196
+ ),
197
+ 2000,
198
+ )
199
+ if exc.code not in retryable_statuses or attempt == max_attempts - 1:
200
+ print(
201
+ f"GitLab API error {exc.code} for {method} {url}: {message}",
202
+ file=sys.stderr,
203
+ )
204
+ return None
205
+ retry_after = _retry_after_seconds(
206
+ exc.headers.get("Retry-After") if exc.headers is not None else None
207
+ )
208
+ last_error = exc
209
+ except GitLabResponseTooLarge as exc:
210
+ print(f"GitLab API response too large for {method} {url}: {exc}", file=sys.stderr)
211
+ return None
212
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
213
+ if attempt == max_attempts - 1:
214
+ print(
215
+ f"GitLab API request failed for {method} {url}: {redact_sensitive(str(exc))}",
216
+ file=sys.stderr,
217
+ )
218
+ return None
219
+ last_error = exc
220
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
221
+ print(f"GitLab API returned invalid JSON for {method} {url}: {exc}", file=sys.stderr)
222
+ return None
223
+
224
+ if retry_after is not None:
225
+ time.sleep(min(max(retry_after, 0.0), 60.0))
226
+ else:
227
+ time.sleep(min(1.0 + attempt, 5.0))
228
+
229
+ if last_error is not None:
230
+ print(
231
+ f"GitLab API request failed for {method} {url}: {redact_sensitive(str(last_error))}",
232
+ file=sys.stderr,
233
+ )
234
+ return None
235
+
236
+
237
+ def _parse_response_body(response: bytes) -> Any:
238
+ """Parse a GitLab response body as JSON or return `{}` for empty success."""
239
+
240
+ if not response:
241
+ return {}
242
+ return json.loads(response.decode("utf-8"))
243
+
244
+
245
+ def _is_invalid_position_error(status: int, body: str) -> bool:
246
+ """Return whether GitLab rejected an inline position without creating a note."""
247
+
248
+ if status not in {400, 422}:
249
+ return False
250
+ field_pattern = r"\b(position|line_code|new_line|old_line|new_path|old_path|base_sha|head_sha|start_sha|diff|line|path)\b"
251
+ reason_pattern = (
252
+ r"\b(invalid|missing|not found|does not exist|can't be blank|"
253
+ r"is required|must be a valid)\b"
254
+ )
255
+ return bool(re.search(rf"(?i){field_pattern}.*{reason_pattern}", body))
256
+
257
+
258
+ def api_write_url_detailed(
259
+ url: str,
260
+ api_token: str,
261
+ auth_header: str,
262
+ data: dict[str, Any],
263
+ method: str = "POST",
264
+ ) -> GitLabWriteResult:
265
+ """Call a non-idempotent GitLab write API without retrying it."""
266
+
267
+ headers = {
268
+ auth_header: api_token,
269
+ "Content-Type": "application/json",
270
+ }
271
+ body = json.dumps(data).encode("utf-8")
272
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
273
+
274
+ try:
275
+ with _open_gitlab_request(request) as response:
276
+ return GitLabWriteResult(
277
+ "posted", _parse_response_body(_read_limited_response(response))
278
+ )
279
+ except urllib.error.HTTPError as exc:
280
+ raw_body = exc.read(MAX_API_ERROR_BODY_BYTES).decode("utf-8", errors="replace")
281
+ safe_body = truncate_plain_text(redact_sensitive(raw_body), 2_000)
282
+ print(f"GitLab API error {exc.code} for {method} {url}: {safe_body}", file=sys.stderr)
283
+ if _is_invalid_position_error(exc.code, raw_body):
284
+ return GitLabWriteResult("invalid_position")
285
+ return GitLabWriteResult("write_failed")
286
+ except GitLabResponseTooLarge as exc:
287
+ print(f"GitLab API response too large for {method} {url}: {exc}", file=sys.stderr)
288
+ return GitLabWriteResult("write_failed")
289
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
290
+ print(
291
+ f"GitLab API request failed for {method} {url}: {redact_sensitive(str(exc))}",
292
+ file=sys.stderr,
293
+ )
294
+ return GitLabWriteResult("write_failed")
295
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
296
+ print(f"GitLab API returned invalid JSON for {method} {url}: {exc}", file=sys.stderr)
297
+ return GitLabWriteResult("write_failed")
298
+
299
+
300
+ def print_user_id_failure_banner(reason: str) -> None:
301
+ """Surface a missing GitLab /user response as a posting precondition failure."""
302
+
303
+ banner = (
304
+ "==================================================================\n"
305
+ " OCR POSTING FAILED: could not resolve the current GitLab user.\n"
306
+ f" Reason: {reason}\n"
307
+ " The script cannot safely identify previous bot notes, so normal\n"
308
+ " review publishing is disabled before creating any GitLab notes.\n"
309
+ " Verify the GitLab API token has /user read access and re-run.\n"
310
+ "=================================================================="
311
+ )
312
+ print(banner, file=sys.stderr)
313
+
314
+
315
+ def fetch_current_user_id(server_url: str, api_token: str, auth_header: str) -> int | None:
316
+ """Return authenticated GitLab user id, or None if it cannot be resolved."""
317
+
318
+ url = f"{server_url}/api/v4/user"
319
+ result = api_request_url(url, api_token, auth_header, method="GET")
320
+
321
+ if not isinstance(result, dict):
322
+ print_user_id_failure_banner("GET /user returned no JSON object")
323
+ return None
324
+
325
+ raw_user_id = result.get("id")
326
+ try:
327
+ return int(raw_user_id)
328
+ except (TypeError, ValueError):
329
+ print_user_id_failure_banner("GET /user response has no valid id field")
330
+ return None
331
+
332
+
333
+ def load_gitlab_config() -> GitLabConfig | None:
334
+ """Load GitLab configuration and choose the correct authentication header."""
335
+
336
+ server_url = getenv("CI_SERVER_URL", "https://gitlab.com").rstrip("/")
337
+ project_id = getenv("CI_PROJECT_ID")
338
+ merge_request_iid = getenv("CI_MERGE_REQUEST_IID")
339
+
340
+ api_token = getenv("GITLAB_API_TOKEN")
341
+
342
+ if not project_id or not merge_request_iid:
343
+ print(
344
+ "Not a merge request pipeline or missing GitLab CI variables.",
345
+ file=sys.stderr,
346
+ )
347
+ return None
348
+
349
+ if not api_token:
350
+ print(
351
+ "Missing GITLAB_API_TOKEN; a dedicated GitLab API token is required for OCR posting.",
352
+ file=sys.stderr,
353
+ )
354
+ return None
355
+
356
+ try:
357
+ parsed_server = urllib.parse.urlsplit(server_url)
358
+ parsed_server_port = parsed_server.port
359
+ parsed_server_hostname = parsed_server.hostname
360
+ parsed_server_username = parsed_server.username
361
+ parsed_server_password = parsed_server.password
362
+ except ValueError:
363
+ print("CI_SERVER_URL must be an absolute HTTPS URL.", file=sys.stderr)
364
+ return None
365
+ if (
366
+ parsed_server.scheme.lower() != "https"
367
+ or not parsed_server_hostname
368
+ or (parsed_server_port is None and parsed_server.netloc.endswith(":"))
369
+ or parsed_server_username is not None
370
+ or parsed_server_password is not None
371
+ or parsed_server.query
372
+ or parsed_server.fragment
373
+ ):
374
+ print("CI_SERVER_URL must be an absolute HTTPS URL.", file=sys.stderr)
375
+ return None
376
+
377
+ auth_header = "PRIVATE-TOKEN"
378
+ current_user_id = fetch_current_user_id(server_url, api_token, auth_header)
379
+
380
+ return GitLabConfig(
381
+ server_url=server_url,
382
+ project_id=project_id,
383
+ merge_request_iid=merge_request_iid,
384
+ api_token=api_token,
385
+ auth_header=auth_header,
386
+ current_user_id=current_user_id,
387
+ )
388
+
389
+
390
+ def api_request(
391
+ config: GitLabConfig,
392
+ endpoint: str,
393
+ data: dict[str, Any] | None = None,
394
+ method: str = "POST",
395
+ ) -> Any | None:
396
+ """Call the GitLab MR API and return parsed JSON, `{}` for empty success, or None."""
397
+
398
+ return api_request_url(
399
+ url=f"{config.api_base}{endpoint}",
400
+ api_token=config.api_token,
401
+ auth_header=config.auth_header,
402
+ data=data,
403
+ method=method,
404
+ )
405
+
406
+
407
+ def api_get_paginated(
408
+ config: GitLabConfig,
409
+ endpoint: str,
410
+ per_page: int = 100,
411
+ max_pages: int = 10,
412
+ ) -> list[Any] | None:
413
+ """Fetch a bounded number of paginated GitLab API results."""
414
+
415
+ items: list[Any] = []
416
+ last_page_size = 0
417
+
418
+ for page in range(1, max_pages + 1):
419
+ separator = "&" if "?" in endpoint else "?"
420
+ page_endpoint = f"{endpoint}{separator}per_page={per_page}&page={page}"
421
+ result = api_request(config, page_endpoint, method="GET")
422
+
423
+ if result is None:
424
+ return None
425
+
426
+ if not isinstance(result, list):
427
+ print(
428
+ f"GitLab API returned an unexpected payload for {page_endpoint}.",
429
+ file=sys.stderr,
430
+ )
431
+ return None
432
+
433
+ if not result:
434
+ return items
435
+
436
+ items.extend(result)
437
+ last_page_size = len(result)
438
+
439
+ if last_page_size < per_page:
440
+ return items
441
+
442
+ separator = "&" if "?" in endpoint else "?"
443
+ sentinel_endpoint = f"{endpoint}{separator}per_page={per_page}&page={max_pages + 1}"
444
+ sentinel = api_request(config, sentinel_endpoint, method="GET")
445
+ if sentinel is None:
446
+ return None
447
+ if not isinstance(sentinel, list):
448
+ print(
449
+ f"GitLab API returned an unexpected payload for {sentinel_endpoint}.",
450
+ file=sys.stderr,
451
+ )
452
+ return None
453
+ if not sentinel:
454
+ return items
455
+
456
+ # Reached max_pages and confirmed at least one extra page. Operators need
457
+ # to see this because un-fetched old bot notes will linger across runs.
458
+ print(
459
+ f"GitLab API pagination hit max_pages={max_pages} for {endpoint} "
460
+ "with additional results; refusing to use an incomplete snapshot.",
461
+ file=sys.stderr,
462
+ )
463
+ return None
464
+
465
+
466
+ @dataclass(frozen=True)
467
+ class DraftNoteCreation:
468
+ """Validated draft note creation response."""
469
+
470
+ response: dict[str, Any]
471
+ note_id: int
472
+
473
+
474
+ def draft_note_id(value: Any) -> int | None:
475
+ """Extract draft note id from a GitLab Draft Notes API object."""
476
+
477
+ if not isinstance(value, dict):
478
+ return None
479
+
480
+ raw_id = value.get("id")
481
+ if isinstance(raw_id, bool):
482
+ return None
483
+
484
+ try:
485
+ return int(raw_id)
486
+ except (TypeError, ValueError):
487
+ return None
488
+
489
+
490
+ def created_draft_note(response: Any, context: str) -> DraftNoteCreation | None:
491
+ """Validate that a create-draft API response contains a publishable id."""
492
+
493
+ if response is None:
494
+ return None
495
+
496
+ note_id = draft_note_id(response)
497
+ if note_id is None:
498
+ print(
499
+ f"GitLab Draft Notes API response for {context} did not contain a usable id.",
500
+ file=sys.stderr,
501
+ )
502
+ return None
503
+
504
+ return DraftNoteCreation(response=response, note_id=note_id)
505
+
506
+
507
+ def delete_plain_note(config: GitLabConfig, note_id: int) -> bool:
508
+ """Delete a top-level merge request note by note id."""
509
+
510
+ response = api_request(config, f"/notes/{note_id}", method="DELETE")
511
+ return response is not None
512
+
513
+
514
+ def delete_discussion_note(config: GitLabConfig, discussion_id: str, note_id: int) -> bool:
515
+ """Delete a note inside a merge request discussion thread."""
516
+
517
+ response = api_request(config, f"/discussions/{discussion_id}/notes/{note_id}", method="DELETE")
518
+ return response is not None
519
+
520
+
521
+ def delete_draft_note(config: GitLabConfig, draft_note_id_value: int) -> bool:
522
+ """Delete a pending draft note owned by the authenticated user."""
523
+
524
+ response = api_request(config, f"/draft_notes/{draft_note_id_value}", method="DELETE")
525
+ return response is not None
526
+
527
+
528
+ def resolve_discussion(config: GitLabConfig, discussion_id: str) -> bool:
529
+ """Mark a merge request discussion as resolved."""
530
+
531
+ response = api_request(
532
+ config,
533
+ f"/discussions/{discussion_id}?resolved=true",
534
+ method="PUT",
535
+ )
536
+ return response is not None
537
+
538
+
539
+ def post_note(config: GitLabConfig, body: str, fingerprint: str | None = None) -> Any | None:
540
+ """Post a normal Merge Request note directly."""
541
+
542
+ return api_request(
543
+ config,
544
+ "/notes",
545
+ {"body": build_marked_note_body(body, fingerprint=fingerprint)},
546
+ )
547
+
548
+
549
+ def post_draft_note(
550
+ config: GitLabConfig,
551
+ body: str,
552
+ position: dict[str, Any] | None = None,
553
+ fingerprint: str | None = None,
554
+ ) -> Any | None:
555
+ """Create a GitLab draft note.
556
+
557
+ If position is provided, the draft note becomes an inline diff draft.
558
+ If position is omitted, it becomes a regular MR draft note.
559
+ """
560
+
561
+ payload: dict[str, Any] = {
562
+ "note": build_marked_note_body(
563
+ body,
564
+ fingerprint=fingerprint,
565
+ max_chars=MAX_INLINE_NOTE_CHARS if position is not None else MAX_NOTE_CHARS,
566
+ inline=position is not None,
567
+ )
568
+ }
569
+ if position is not None:
570
+ payload["position"] = position
571
+
572
+ return api_request(config, "/draft_notes", payload)
573
+
574
+
575
+ def post_inline_note_detailed(
576
+ config: GitLabConfig,
577
+ path: str,
578
+ line: int,
579
+ body: str,
580
+ refs: dict[str, str],
581
+ fingerprint: str | None = None,
582
+ old_path: str | None = None,
583
+ ) -> GitLabWriteResult:
584
+ """Post a direct inline note and distinguish invalid positions from write failures."""
585
+
586
+ return api_write_url_detailed(
587
+ url=f"{config.api_base}/discussions",
588
+ api_token=config.api_token,
589
+ auth_header=config.auth_header,
590
+ data={
591
+ "body": build_marked_note_body(
592
+ body,
593
+ fingerprint=fingerprint,
594
+ max_chars=MAX_INLINE_NOTE_CHARS,
595
+ inline=True,
596
+ ),
597
+ "position": build_text_position(path, line, refs, old_path=old_path),
598
+ },
599
+ )
600
+
601
+
602
+ def post_inline_draft_note_detailed(
603
+ config: GitLabConfig,
604
+ path: str,
605
+ line: int,
606
+ body: str,
607
+ refs: dict[str, str],
608
+ fingerprint: str | None = None,
609
+ old_path: str | None = None,
610
+ ) -> GitLabWriteResult:
611
+ """Create an inline draft note and distinguish invalid positions from write failures."""
612
+
613
+ marked_body = build_marked_note_body(
614
+ body,
615
+ fingerprint=fingerprint,
616
+ max_chars=MAX_INLINE_NOTE_CHARS,
617
+ inline=True,
618
+ )
619
+ return api_write_url_detailed(
620
+ url=f"{config.api_base}/draft_notes",
621
+ api_token=config.api_token,
622
+ auth_header=config.auth_header,
623
+ data={
624
+ "note": marked_body,
625
+ "position": build_text_position(path, line, refs, old_path=old_path),
626
+ },
627
+ )
628
+
629
+
630
+ def publish_draft_note(config: GitLabConfig, draft_note_id_value: int) -> bool:
631
+ """Publish one draft note created by this script run."""
632
+
633
+ response = api_request(
634
+ config,
635
+ f"/draft_notes/{draft_note_id_value}/publish",
636
+ method="PUT",
637
+ )
638
+ return response is not None
639
+
640
+
641
+ def publish_created_draft_notes(config: GitLabConfig, draft_note_ids: list[int]) -> bool:
642
+ """Publish only draft notes created by this script run."""
643
+
644
+ if post_mode() != "draft":
645
+ return True
646
+
647
+ for note_id in draft_note_ids:
648
+ if not publish_draft_note(config, note_id):
649
+ print(f"Failed to publish OCR draft note id={note_id}.", file=sys.stderr)
650
+ return False
651
+
652
+ return True
653
+
654
+
655
+ def post_review_note(
656
+ config: GitLabConfig,
657
+ body: str,
658
+ draft_note_ids: list[int],
659
+ fingerprint: str | None = None,
660
+ ) -> Any | None:
661
+ """Post a regular MR note using the configured posting mode."""
662
+
663
+ if post_mode() == "draft":
664
+ response = post_draft_note(config, body, fingerprint=fingerprint)
665
+ created = created_draft_note(response, "MR note draft")
666
+ if created is None:
667
+ return None
668
+ draft_note_ids.append(created.note_id)
669
+ return created.response
670
+
671
+ return post_note(config, body, fingerprint=fingerprint)
672
+
673
+
674
+ def post_review_note_bounded(
675
+ config: GitLabConfig,
676
+ title: str,
677
+ body: str,
678
+ draft_note_ids: list[int],
679
+ fingerprint: str | None = None,
680
+ ) -> Any | None:
681
+ """Post a bounded regular MR note using the configured posting mode."""
682
+
683
+ body_budget = note_body_budget(MAX_NOTE_CHARS, fingerprint)
684
+ full_body = f"{title}\n\n{body}"
685
+ if len(full_body) <= body_budget:
686
+ return post_review_note(config, full_body, draft_note_ids, fingerprint=fingerprint)
687
+
688
+ content_budget = max(0, body_budget - len(title) - 2)
689
+ bounded_body = truncate_note_body(body, max_chars=content_budget)
690
+ final_body = f"{title}\n\n{bounded_body}"
691
+ if len(final_body) > body_budget:
692
+ final_body = truncate_note_body(final_body, max_chars=body_budget)
693
+
694
+ return post_review_note(
695
+ config,
696
+ final_body,
697
+ draft_note_ids,
698
+ fingerprint=fingerprint,
699
+ )
700
+
701
+
702
+ def get_diff_refs(config: GitLabConfig) -> dict[str, str] | None:
703
+ """Fetch GitLab MR diff refs for the commit reviewed by this CI job."""
704
+
705
+ versions = api_get_paginated(config, "/versions", max_pages=20)
706
+ if not isinstance(versions, list) or not versions:
707
+ print(
708
+ "GitLab MR versions are unavailable; inline comments will fall back to notes.",
709
+ file=sys.stderr,
710
+ )
711
+ return None
712
+
713
+ candidate_head_shas: list[str] = []
714
+ for sha in (
715
+ getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA").strip(),
716
+ getenv("CI_COMMIT_SHA").strip(),
717
+ ):
718
+ if sha and sha not in candidate_head_shas and not re.fullmatch(r"0+", sha):
719
+ candidate_head_shas.append(sha)
720
+ if not candidate_head_shas:
721
+ print(
722
+ "CI MR source/head commit SHA is unavailable; inline comments will fall back to notes.",
723
+ file=sys.stderr,
724
+ )
725
+ return None
726
+
727
+ matching_version = None
728
+ for candidate_sha in candidate_head_shas:
729
+ for version in versions:
730
+ if not isinstance(version, dict):
731
+ continue
732
+ if str(version.get("head_commit_sha") or "") == candidate_sha:
733
+ matching_version = version
734
+ break
735
+ if matching_version is not None:
736
+ break
737
+
738
+ if matching_version is None:
739
+ print(
740
+ "GitLab MR versions do not include CI head SHA candidate(s) "
741
+ f"{', '.join(candidate_head_shas)}; "
742
+ "inline comments will fall back to notes.",
743
+ file=sys.stderr,
744
+ )
745
+ return None
746
+
747
+ refs = {
748
+ "base_sha": str(matching_version.get("base_commit_sha", "")),
749
+ "start_sha": str(matching_version.get("start_commit_sha", "")),
750
+ "head_sha": str(matching_version.get("head_commit_sha", "")),
751
+ }
752
+
753
+ if not all(refs.values()):
754
+ print(
755
+ "GitLab MR version for CI head SHA lacks complete diff refs.",
756
+ file=sys.stderr,
757
+ )
758
+ return None
759
+
760
+ return refs
761
+
762
+
763
+ def build_text_position(
764
+ path: str,
765
+ line: int,
766
+ refs: dict[str, str],
767
+ old_path: str | None = None,
768
+ ) -> dict[str, Any]:
769
+ """Build a GitLab text diff position for an added/new line."""
770
+
771
+ return {
772
+ "position_type": "text",
773
+ "new_path": path,
774
+ "old_path": old_path or path,
775
+ "new_line": line,
776
+ "base_sha": refs["base_sha"],
777
+ "start_sha": refs["start_sha"],
778
+ "head_sha": refs["head_sha"],
779
+ }
780
+
781
+
782
+ def post_discussion(
783
+ config: GitLabConfig,
784
+ path: str,
785
+ line: int,
786
+ body: str,
787
+ refs: dict[str, str],
788
+ fingerprint: str | None = None,
789
+ old_path: str | None = None,
790
+ ) -> Any | None:
791
+ """Post an inline discussion directly on a specific new-line position."""
792
+
793
+ return api_request(
794
+ config,
795
+ "/discussions",
796
+ {
797
+ "body": build_marked_note_body(
798
+ body,
799
+ fingerprint=fingerprint,
800
+ max_chars=MAX_INLINE_NOTE_CHARS,
801
+ inline=True,
802
+ ),
803
+ "position": build_text_position(path, line, refs, old_path=old_path),
804
+ },
805
+ )
806
+
807
+
808
+ def post_draft_discussion(
809
+ config: GitLabConfig,
810
+ path: str,
811
+ line: int,
812
+ body: str,
813
+ refs: dict[str, str],
814
+ fingerprint: str | None = None,
815
+ old_path: str | None = None,
816
+ ) -> Any | None:
817
+ """Create an inline draft discussion on a specific new-line position."""
818
+
819
+ return post_draft_note(
820
+ config,
821
+ body,
822
+ position=build_text_position(path, line, refs, old_path=old_path),
823
+ fingerprint=fingerprint,
824
+ )
825
+
826
+
827
+ def post_review_discussion(
828
+ config: GitLabConfig,
829
+ path: str,
830
+ line: int,
831
+ body: str,
832
+ refs: dict[str, str],
833
+ draft_note_ids: list[int],
834
+ fingerprint: str | None = None,
835
+ old_path: str | None = None,
836
+ ) -> GitLabWriteResult:
837
+ """Post an inline discussion using the configured posting mode."""
838
+
839
+ if post_mode() == "draft":
840
+ result = post_inline_draft_note_detailed(
841
+ config, path, line, body, refs, fingerprint=fingerprint, old_path=old_path
842
+ )
843
+ if not result.posted:
844
+ return result
845
+ created = created_draft_note(result.response, "inline discussion draft")
846
+ if created is None:
847
+ return GitLabWriteResult("write_failed")
848
+ draft_note_ids.append(created.note_id)
849
+ return GitLabWriteResult("posted", created.response)
850
+
851
+ return post_inline_note_detailed(
852
+ config, path, line, body, refs, fingerprint=fingerprint, old_path=old_path
853
+ )