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,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,8 @@
1
+ """CLI entrypoint for posting OCR results to GitLab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ocr_toolkit.posting.workflow import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
40
+ escaped = escaped.replace("@", "&#64;")
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