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