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,97 @@
|
|
|
1
|
+
"""Settings and small value helpers for OCR review context generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ocr_toolkit.common.markdown import inline_code as _inline_code
|
|
10
|
+
|
|
11
|
+
DEFAULT_MAX_FILE_BYTES = 256_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAX_BACKGROUND_SECTION_ITEMS = 120
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_INSTRUCTION_BYTES = 24_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_BACKGROUND_MAX_BYTES = 65_536
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MAX_BACKGROUND_MAX_BYTES = 1_048_576
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_BACKGROUND_MAX_CHARS = 7_950
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MAX_BACKGROUND_MAX_CHARS = 7_950
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DEFAULT_MANIFEST_PARSE_MAX_BYTES = 2_000_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
MAX_MANIFEST_PARSE_MAX_BYTES = 5_000_000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
JSON_OBJECT_PARSE_ERROR = "top-level JSON value is not an object"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def getenv_int(name: str, default: int, max_value: int | None = None) -> int:
|
|
42
|
+
"""Read a positive integer environment variable."""
|
|
43
|
+
|
|
44
|
+
raw = os.environ.get(name)
|
|
45
|
+
if not raw:
|
|
46
|
+
return default
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
value = int(raw)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
if value <= 0:
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
if max_value is not None and value > max_value:
|
|
57
|
+
return max_value
|
|
58
|
+
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def inline_code(value: str) -> str:
|
|
63
|
+
"""Return a Markdown inline-code representation safe for backticks.
|
|
64
|
+
|
|
65
|
+
Background output mixes arbitrary strings from CI env vars and parsed
|
|
66
|
+
manifests, so escape control characters before fencing.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
return _inline_code(value, escape_controls=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def string_value(value: Any) -> str | None:
|
|
73
|
+
"""Return value only when it is already a string."""
|
|
74
|
+
|
|
75
|
+
return value if isinstance(value, str) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def string_list_value(value: Any) -> list[str]:
|
|
79
|
+
"""Return only string items from a list-like manifest value."""
|
|
80
|
+
|
|
81
|
+
if not isinstance(value, list):
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
return [item for item in value if isinstance(item, str)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_env_file(path: str) -> bool:
|
|
88
|
+
"""Return whether a repo path names an environment variable file."""
|
|
89
|
+
|
|
90
|
+
name = Path(path).name.lower()
|
|
91
|
+
return (
|
|
92
|
+
name == ".env"
|
|
93
|
+
or name.startswith((".env.", ".env-"))
|
|
94
|
+
or ".env." in name
|
|
95
|
+
or ".env-" in name
|
|
96
|
+
or name.endswith(".env")
|
|
97
|
+
)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Configure OCR MCP servers from CI environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ocr_toolkit.common.redaction import redact_sensitive
|
|
14
|
+
from ocr_toolkit.config_writer import OCRConfigError, update_ocr_config
|
|
15
|
+
|
|
16
|
+
MAX_MCP_CONFIG_BYTES = 64_000
|
|
17
|
+
MAX_MCP_SERVERS = 16
|
|
18
|
+
MAX_MCP_ARGS = 64
|
|
19
|
+
MAX_MCP_ENV = 64
|
|
20
|
+
MAX_MCP_TOOLS = 128
|
|
21
|
+
MCP_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
22
|
+
MCP_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCPConfigError(Exception):
|
|
26
|
+
"""The MCP CI configuration is invalid."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class MCPServerConfig:
|
|
31
|
+
"""Validated OCR MCP server configuration for one stdio bridge."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
command: str
|
|
35
|
+
args: list[str]
|
|
36
|
+
tools: list[str]
|
|
37
|
+
setup: str
|
|
38
|
+
env: list[str]
|
|
39
|
+
secret_values: list[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _string_list(value: Any, field: str, *, limit: int) -> list[str]:
|
|
43
|
+
if not isinstance(value, list):
|
|
44
|
+
raise MCPConfigError(f"{field} must be a JSON array of strings")
|
|
45
|
+
if len(value) > limit:
|
|
46
|
+
raise MCPConfigError(f"{field} exceeds maximum item count {limit}")
|
|
47
|
+
result: list[str] = []
|
|
48
|
+
for index, item in enumerate(value):
|
|
49
|
+
if not isinstance(item, str):
|
|
50
|
+
raise MCPConfigError(f"{field}[{index}] must be a string")
|
|
51
|
+
result.append(item)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _env_assignments(value: Any, env_from: Any, server_name: str) -> tuple[list[str], list[str]]:
|
|
56
|
+
assignments: list[str] = []
|
|
57
|
+
secret_values: list[str] = []
|
|
58
|
+
|
|
59
|
+
if value is None:
|
|
60
|
+
value = {}
|
|
61
|
+
if env_from is None:
|
|
62
|
+
env_from = {}
|
|
63
|
+
if not isinstance(value, dict):
|
|
64
|
+
raise MCPConfigError(f"servers.{server_name}.env must be an object")
|
|
65
|
+
if not isinstance(env_from, dict):
|
|
66
|
+
raise MCPConfigError(f"servers.{server_name}.env_from must be an object")
|
|
67
|
+
if len(value) + len(env_from) > MAX_MCP_ENV:
|
|
68
|
+
raise MCPConfigError(
|
|
69
|
+
f"servers.{server_name}.env/env_from exceed maximum item count {MAX_MCP_ENV}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
for key, item in value.items():
|
|
73
|
+
if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
|
|
74
|
+
raise MCPConfigError(f"servers.{server_name}.env contains invalid env name")
|
|
75
|
+
if not isinstance(item, str):
|
|
76
|
+
raise MCPConfigError(f"servers.{server_name}.env.{key} must be a string")
|
|
77
|
+
assignments.append(f"{key}={item}")
|
|
78
|
+
|
|
79
|
+
for key, source_name in env_from.items():
|
|
80
|
+
if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
|
|
81
|
+
raise MCPConfigError(f"servers.{server_name}.env_from contains invalid env name")
|
|
82
|
+
if key in value:
|
|
83
|
+
raise MCPConfigError(
|
|
84
|
+
f"servers.{server_name}.env_from.{key} duplicates servers.{server_name}.env.{key}"
|
|
85
|
+
)
|
|
86
|
+
if not isinstance(source_name, str) or not MCP_ENV_NAME_RE.fullmatch(source_name):
|
|
87
|
+
raise MCPConfigError(f"servers.{server_name}.env_from.{key} must name a CI variable")
|
|
88
|
+
if source_name not in os.environ:
|
|
89
|
+
raise MCPConfigError(
|
|
90
|
+
f"servers.{server_name}.env_from.{key} references missing CI variable {source_name}"
|
|
91
|
+
)
|
|
92
|
+
secret_value = os.environ[source_name]
|
|
93
|
+
assignments.append(f"{key}={secret_value}")
|
|
94
|
+
if secret_value:
|
|
95
|
+
secret_values.append(secret_value)
|
|
96
|
+
|
|
97
|
+
return assignments, secret_values
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
|
|
101
|
+
"""Return validated MCP server configs from OCR_MCP_SERVERS_JSON."""
|
|
102
|
+
|
|
103
|
+
raw = os.environ.get("OCR_MCP_SERVERS_JSON", "") if raw is None else raw
|
|
104
|
+
raw = raw.strip()
|
|
105
|
+
if not raw:
|
|
106
|
+
return []
|
|
107
|
+
if len(raw.encode("utf-8")) > MAX_MCP_CONFIG_BYTES:
|
|
108
|
+
raise MCPConfigError(f"OCR_MCP_SERVERS_JSON exceeds {MAX_MCP_CONFIG_BYTES} bytes")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
payload = json.loads(raw)
|
|
112
|
+
except json.JSONDecodeError as exc:
|
|
113
|
+
raise MCPConfigError(
|
|
114
|
+
"OCR_MCP_SERVERS_JSON is not valid JSON; check CI variable formatting"
|
|
115
|
+
) from exc
|
|
116
|
+
except RecursionError as exc:
|
|
117
|
+
raise MCPConfigError("OCR_MCP_SERVERS_JSON is too deeply nested") from exc
|
|
118
|
+
|
|
119
|
+
if not isinstance(payload, dict):
|
|
120
|
+
raise MCPConfigError("OCR_MCP_SERVERS_JSON top-level value must be an object")
|
|
121
|
+
if "servers" in payload:
|
|
122
|
+
if len(payload) != 1:
|
|
123
|
+
raise MCPConfigError(
|
|
124
|
+
"OCR_MCP_SERVERS_JSON cannot mix the servers envelope with named servers"
|
|
125
|
+
)
|
|
126
|
+
servers_value = payload["servers"]
|
|
127
|
+
if not isinstance(servers_value, list):
|
|
128
|
+
raise MCPConfigError("OCR_MCP_SERVERS_JSON.servers must be an array")
|
|
129
|
+
else:
|
|
130
|
+
servers_value = []
|
|
131
|
+
for name, value in payload.items():
|
|
132
|
+
if not isinstance(value, dict):
|
|
133
|
+
raise MCPConfigError(f"OCR MCP server {name!r} configuration must be an object")
|
|
134
|
+
if "name" in value and value["name"] != name:
|
|
135
|
+
raise MCPConfigError(f"OCR MCP server {name!r} must not declare a different name")
|
|
136
|
+
servers_value.append({**value, "name": name})
|
|
137
|
+
if len(servers_value) > MAX_MCP_SERVERS:
|
|
138
|
+
raise MCPConfigError(f"OCR_MCP_SERVERS_JSON has more than {MAX_MCP_SERVERS} servers")
|
|
139
|
+
|
|
140
|
+
seen: set[str] = set()
|
|
141
|
+
servers: list[MCPServerConfig] = []
|
|
142
|
+
for index, server in enumerate(servers_value):
|
|
143
|
+
if not isinstance(server, dict):
|
|
144
|
+
raise MCPConfigError(f"servers[{index}] must be an object")
|
|
145
|
+
enabled = server.get("enabled", True)
|
|
146
|
+
if not isinstance(enabled, bool):
|
|
147
|
+
raise MCPConfigError(f"servers[{index}].enabled must be a boolean")
|
|
148
|
+
if enabled is False:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
name = server.get("name")
|
|
152
|
+
if not isinstance(name, str) or not MCP_NAME_RE.fullmatch(name):
|
|
153
|
+
raise MCPConfigError(f"servers[{index}].name is invalid")
|
|
154
|
+
if name in seen:
|
|
155
|
+
raise MCPConfigError(f"duplicate MCP server name {name!r}")
|
|
156
|
+
seen.add(name)
|
|
157
|
+
|
|
158
|
+
command = server.get("command")
|
|
159
|
+
if not isinstance(command, str) or not command.strip():
|
|
160
|
+
raise MCPConfigError(f"servers.{name}.command must be a non-empty string")
|
|
161
|
+
command = command.strip()
|
|
162
|
+
|
|
163
|
+
args = _string_list(server.get("args", []), f"servers.{name}.args", limit=MAX_MCP_ARGS)
|
|
164
|
+
tools = _string_list(server.get("tools", []), f"servers.{name}.tools", limit=MAX_MCP_TOOLS)
|
|
165
|
+
setup = server.get("setup", "")
|
|
166
|
+
if not isinstance(setup, str):
|
|
167
|
+
raise MCPConfigError(f"servers.{name}.setup must be a string")
|
|
168
|
+
|
|
169
|
+
env, secret_values = _env_assignments(server.get("env"), server.get("env_from"), name)
|
|
170
|
+
servers.append(
|
|
171
|
+
MCPServerConfig(
|
|
172
|
+
name=name,
|
|
173
|
+
command=command,
|
|
174
|
+
args=args,
|
|
175
|
+
tools=tools,
|
|
176
|
+
setup=setup,
|
|
177
|
+
env=env,
|
|
178
|
+
secret_values=secret_values,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return servers
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _redact_extra_values(text: str, values: list[str]) -> str:
|
|
186
|
+
redacted = text
|
|
187
|
+
for value in values:
|
|
188
|
+
if value and len(value) >= 4:
|
|
189
|
+
for variant in {
|
|
190
|
+
value,
|
|
191
|
+
urllib.parse.quote(value, safe=""),
|
|
192
|
+
urllib.parse.quote_plus(value, safe=""),
|
|
193
|
+
}:
|
|
194
|
+
if variant:
|
|
195
|
+
redacted = redacted.replace(variant, "***")
|
|
196
|
+
return redacted
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def configure_mcp_servers() -> int:
|
|
200
|
+
"""Configure OCR MCP servers from CI and return a process exit code."""
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
servers = parse_mcp_servers()
|
|
204
|
+
except MCPConfigError as exc:
|
|
205
|
+
print(f"Invalid OCR_MCP_SERVERS_JSON: {redact_sensitive(str(exc))}", file=sys.stderr)
|
|
206
|
+
return 1
|
|
207
|
+
|
|
208
|
+
if not servers:
|
|
209
|
+
try:
|
|
210
|
+
update_ocr_config({"mcp_servers": {}})
|
|
211
|
+
except OCRConfigError as exc:
|
|
212
|
+
print(
|
|
213
|
+
f"Failed to clear OCR MCP servers: {redact_sensitive(str(exc))}",
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
)
|
|
216
|
+
return 1
|
|
217
|
+
print("OCR MCP servers: none configured")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
mcp_servers: dict[str, Any] = {}
|
|
221
|
+
secret_values: list[str] = []
|
|
222
|
+
for server in servers:
|
|
223
|
+
mcp_servers[server.name] = {
|
|
224
|
+
"command": server.command,
|
|
225
|
+
"args": server.args,
|
|
226
|
+
"env": server.env,
|
|
227
|
+
"tools": server.tools,
|
|
228
|
+
"setup": server.setup,
|
|
229
|
+
}
|
|
230
|
+
secret_values.extend(server.secret_values)
|
|
231
|
+
for assignment in server.env:
|
|
232
|
+
_env_name, _sep, env_value = assignment.partition("=")
|
|
233
|
+
if env_value:
|
|
234
|
+
secret_values.append(env_value)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
update_ocr_config({"mcp_servers": mcp_servers})
|
|
238
|
+
except OCRConfigError as exc:
|
|
239
|
+
print(
|
|
240
|
+
"Failed to configure OCR MCP servers: "
|
|
241
|
+
f"{redact_sensitive(_redact_extra_values(str(exc), secret_values))}",
|
|
242
|
+
file=sys.stderr,
|
|
243
|
+
)
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
for server in servers:
|
|
247
|
+
print(
|
|
248
|
+
"OCR MCP server configured: "
|
|
249
|
+
f"{server.name} command={server.command!r} "
|
|
250
|
+
f"args={len(server.args)} env={len(server.env)} tools={len(server.tools)}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
raise SystemExit(configure_mcp_servers())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Open Code Review CI helper package."""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Small helpers for OCR comment value normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ocr_toolkit.common.markdown import escape_control_chars
|
|
9
|
+
|
|
10
|
+
MARKDOWN_INLINE_SPECIAL_RE = re.compile(r"([`*_{}\[\]()+.!|~-])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def clean_text(value: Any) -> str:
|
|
14
|
+
"""Convert a JSON value to a stripped string."""
|
|
15
|
+
|
|
16
|
+
return "" if value is None else str(value).strip()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compact_text(value: str, max_chars: int) -> str:
|
|
20
|
+
"""Collapse whitespace and bound text for one-line MR summaries."""
|
|
21
|
+
|
|
22
|
+
if max_chars <= 0:
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
collapsed = " ".join(value.split())
|
|
26
|
+
if len(collapsed) <= max_chars:
|
|
27
|
+
return collapsed
|
|
28
|
+
|
|
29
|
+
if max_chars <= 3:
|
|
30
|
+
return "." * max_chars
|
|
31
|
+
|
|
32
|
+
return collapsed[: max_chars - 3].rstrip() + "..."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compact_escaped_text(value: str, max_chars: int) -> str:
|
|
36
|
+
"""Escape Markdown-sensitive text before compacting MR summary snippets."""
|
|
37
|
+
|
|
38
|
+
escaped = escape_control_chars(value)
|
|
39
|
+
escaped = escaped.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
40
|
+
escaped = escaped.replace("@", "@")
|
|
41
|
+
escaped = MARKDOWN_INLINE_SPECIAL_RE.sub(r"\\\1", escaped)
|
|
42
|
+
return compact_text(escaped, max_chars)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compact_control_text(value: str, max_chars: int) -> str:
|
|
46
|
+
"""Escape controls only before compacting text rendered inside inline code."""
|
|
47
|
+
|
|
48
|
+
return compact_text(escape_control_chars(value), max_chars)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def code_text(value: Any) -> str:
|
|
52
|
+
"""Convert a JSON value to text while preserving code indentation."""
|
|
53
|
+
|
|
54
|
+
if value is None:
|
|
55
|
+
return ""
|
|
56
|
+
return str(value).rstrip("\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def line_number(value: Any) -> int:
|
|
60
|
+
"""Parse a line number, returning zero when invalid."""
|
|
61
|
+
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return 0
|
|
64
|
+
if isinstance(value, int):
|
|
65
|
+
parsed = value
|
|
66
|
+
elif isinstance(value, str):
|
|
67
|
+
text = value.strip()
|
|
68
|
+
if not text.isdecimal():
|
|
69
|
+
return 0
|
|
70
|
+
parsed = int(text)
|
|
71
|
+
else:
|
|
72
|
+
return 0
|
|
73
|
+
return parsed if parsed > 0 else 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def comment_line(comment: dict[str, Any]) -> int:
|
|
77
|
+
"""Return the best new-line number for a GitLab inline discussion.
|
|
78
|
+
|
|
79
|
+
Anchor on the start of the range so multi-line findings highlight the
|
|
80
|
+
first problematic line rather than the end of the span.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
for key in ("start_line", "line", "end_line"):
|
|
84
|
+
parsed = line_number(comment.get(key))
|
|
85
|
+
if parsed > 0:
|
|
86
|
+
return parsed
|
|
87
|
+
return 0
|