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