open-code-review-toolkit 0.3.0__tar.gz → 0.3.1__tar.gz
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.
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/PKG-INFO +3 -3
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/README.md +2 -2
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/_version.py +2 -2
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/cli.py +18 -1
- open_code_review_toolkit-0.3.1/src/ocr_toolkit/mcp_config.py +479 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/workflow.py +1 -27
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/preflight.py +1 -1
- open_code_review_toolkit-0.3.1/src/ocr_toolkit/review_runner.py +117 -0
- open_code_review_toolkit-0.3.0/src/ocr_toolkit/mcp_config.py +0 -257
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/.gitignore +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/LICENSE +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/pyproject.toml +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/__init__.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/__init__.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/language.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/markdown.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/redaction.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/config_writer.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/configure.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/__init__.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/__main__.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/ansible.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/categorize.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/instructions.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/manifests.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/planner.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/render.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/repo.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/settings.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/__init__.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/__main__.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/comments.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/formatting.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/gitlab.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/markers.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/payloads.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/result.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/settings.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/snapshot.py +0 -0
- {open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: open-code-review-toolkit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Unofficial GitLab CI integration layer for Open Code Review
|
|
5
5
|
Project-URL: Homepage, https://github.com/xeonvs/open-code-review-toolkit
|
|
6
6
|
Project-URL: Repository, https://github.com/xeonvs/open-code-review-toolkit
|
|
@@ -243,7 +243,7 @@ ocr --version
|
|
|
243
243
|
ocr-ci --help
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
-
The current compatibility target is OCR `1.
|
|
246
|
+
The current compatibility target is OCR `1.8.0`. CI should pin the release and verify its published checksum before execution.
|
|
247
247
|
The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
|
|
248
248
|
Review output defaults to English. Set `OCR_REVIEW_LANGUAGE=Russian` to use Russian consistently in both OCR configuration and generated review context.
|
|
249
249
|
|
|
@@ -277,7 +277,7 @@ ocr-ci preflight
|
|
|
277
277
|
ocr-ci configure
|
|
278
278
|
ocr-ci mcp-config
|
|
279
279
|
ocr-ci context --output .review-context/dependencies.md
|
|
280
|
-
|
|
280
|
+
ocr-ci review --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log -- ... --format json
|
|
281
281
|
ocr-ci post --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log
|
|
282
282
|
```
|
|
283
283
|
|
|
@@ -15,7 +15,7 @@ ocr --version
|
|
|
15
15
|
ocr-ci --help
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
The current compatibility target is OCR `1.
|
|
18
|
+
The current compatibility target is OCR `1.8.0`. CI should pin the release and verify its published checksum before execution.
|
|
19
19
|
The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
|
|
20
20
|
Review output defaults to English. Set `OCR_REVIEW_LANGUAGE=Russian` to use Russian consistently in both OCR configuration and generated review context.
|
|
21
21
|
|
|
@@ -49,7 +49,7 @@ ocr-ci preflight
|
|
|
49
49
|
ocr-ci configure
|
|
50
50
|
ocr-ci mcp-config
|
|
51
51
|
ocr-ci context --output .review-context/dependencies.md
|
|
52
|
-
|
|
52
|
+
ocr-ci review --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log -- ... --format json
|
|
53
53
|
ocr-ci post --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log
|
|
54
54
|
```
|
|
55
55
|
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/_version.py
RENAMED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.3.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import sys
|
|
6
7
|
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
7
9
|
|
|
8
|
-
from ocr_toolkit import configure, mcp_config, preflight
|
|
10
|
+
from ocr_toolkit import configure, mcp_config, preflight, review_runner
|
|
9
11
|
from ocr_toolkit.context import render as context_render
|
|
10
12
|
from ocr_toolkit.posting.workflow import main as posting_main
|
|
11
13
|
|
|
@@ -21,6 +23,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
21
23
|
subparsers.add_parser("preflight", help="Validate OCR, GitLab, and LLM access.")
|
|
22
24
|
subparsers.add_parser("configure", help="Write the OCR runtime configuration.")
|
|
23
25
|
subparsers.add_parser("mcp-config", help="Write OCR MCP server configuration.")
|
|
26
|
+
review_parser = subparsers.add_parser(
|
|
27
|
+
"review", help="Run OCR with private artifacts and safe failure diagnostics."
|
|
28
|
+
)
|
|
29
|
+
review_parser.add_argument("--result", required=True, help="OCR JSON output path.")
|
|
30
|
+
review_parser.add_argument("--stderr", required=True, help="Full OCR stderr artifact path.")
|
|
31
|
+
review_parser.add_argument(
|
|
32
|
+
"ocr_args", nargs=argparse.REMAINDER, help="OCR review arguments after --."
|
|
33
|
+
)
|
|
24
34
|
|
|
25
35
|
context_parser = subparsers.add_parser(
|
|
26
36
|
"context", help="Generate bounded repository review context."
|
|
@@ -57,6 +67,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
57
67
|
return configure.main()
|
|
58
68
|
if args.command == "mcp-config":
|
|
59
69
|
return mcp_config.configure_mcp_servers()
|
|
70
|
+
if args.command == "review":
|
|
71
|
+
ocr_args = args.ocr_args[1:] if args.ocr_args[:1] == ["--"] else args.ocr_args
|
|
72
|
+
try:
|
|
73
|
+
return review_runner.run_review(Path(args.result), Path(args.stderr), ocr_args)
|
|
74
|
+
except review_runner.ReviewRunnerError as exc:
|
|
75
|
+
print(f"Cannot run Open Code Review: {exc}", file=sys.stderr)
|
|
76
|
+
return 2
|
|
60
77
|
if args.command == "context":
|
|
61
78
|
return context_render.main(["--output", args.output])
|
|
62
79
|
if args.command == "post":
|
|
@@ -0,0 +1,479 @@
|
|
|
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, read_ocr_config, 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_HEADERS = 32
|
|
21
|
+
MAX_MCP_TOOLS = 128
|
|
22
|
+
MAX_MCP_URL_CHARS = 2_048
|
|
23
|
+
MAX_MCP_HEADER_VALUE_CHARS = 2_048
|
|
24
|
+
MAX_MCP_SETUP_CHARS = 4_096
|
|
25
|
+
MAX_MCP_STRING_CHARS = 4_096
|
|
26
|
+
MCP_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
27
|
+
MCP_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
28
|
+
MCP_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
29
|
+
SENSITIVE_HEADER_NAME_RE = re.compile(
|
|
30
|
+
r"(?:^|[-_])(?:authorization|authentication|auth|cookie|api[-_]?key|"
|
|
31
|
+
r"subscription[-_]?key|key|token|secret|credential)(?:$|[-_])",
|
|
32
|
+
flags=re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
TRUE_VALUES = {"1", "true", "yes", "on"}
|
|
35
|
+
FALSE_VALUES = {"0", "false", "no", "off", ""}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MCPConfigError(Exception):
|
|
39
|
+
"""The MCP CI configuration is invalid."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class MCPServerConfig:
|
|
44
|
+
"""Validated OCR MCP server configuration for one transport."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
transport: str
|
|
48
|
+
command: str | None
|
|
49
|
+
url: str | None
|
|
50
|
+
args: list[str]
|
|
51
|
+
tools: list[str]
|
|
52
|
+
setup: str
|
|
53
|
+
env: list[str]
|
|
54
|
+
headers: dict[str, str]
|
|
55
|
+
secret_values: list[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _string_list(value: Any, field: str, *, limit: int) -> list[str]:
|
|
59
|
+
"""Validate one bounded JSON string list and preserve its order."""
|
|
60
|
+
|
|
61
|
+
if not isinstance(value, list):
|
|
62
|
+
raise MCPConfigError(f"{field} must be a JSON array of strings")
|
|
63
|
+
if len(value) > limit:
|
|
64
|
+
raise MCPConfigError(f"{field} exceeds maximum item count {limit}")
|
|
65
|
+
result: list[str] = []
|
|
66
|
+
for index, item in enumerate(value):
|
|
67
|
+
if not isinstance(item, str):
|
|
68
|
+
raise MCPConfigError(f"{field}[{index}] must be a string")
|
|
69
|
+
if len(item) > MAX_MCP_STRING_CHARS:
|
|
70
|
+
raise MCPConfigError(f"{field}[{index}] exceeds maximum length {MAX_MCP_STRING_CHARS}")
|
|
71
|
+
result.append(item)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _env_assignments(value: Any, env_from: Any, server_name: str) -> tuple[list[str], list[str]]:
|
|
76
|
+
"""Build stdio environment assignments and collect values for redaction."""
|
|
77
|
+
|
|
78
|
+
assignments: list[str] = []
|
|
79
|
+
secret_values: list[str] = []
|
|
80
|
+
|
|
81
|
+
if value is None:
|
|
82
|
+
value = {}
|
|
83
|
+
if env_from is None:
|
|
84
|
+
env_from = {}
|
|
85
|
+
if not isinstance(value, dict):
|
|
86
|
+
raise MCPConfigError(f"servers.{server_name}.env must be an object")
|
|
87
|
+
if not isinstance(env_from, dict):
|
|
88
|
+
raise MCPConfigError(f"servers.{server_name}.env_from must be an object")
|
|
89
|
+
if len(value) + len(env_from) > MAX_MCP_ENV:
|
|
90
|
+
raise MCPConfigError(
|
|
91
|
+
f"servers.{server_name}.env/env_from exceed maximum item count {MAX_MCP_ENV}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for key, item in value.items():
|
|
95
|
+
if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
|
|
96
|
+
raise MCPConfigError(f"servers.{server_name}.env contains invalid env name")
|
|
97
|
+
if not isinstance(item, str):
|
|
98
|
+
raise MCPConfigError(f"servers.{server_name}.env.{key} must be a string")
|
|
99
|
+
if len(key) + len(item) + 1 > MAX_MCP_STRING_CHARS:
|
|
100
|
+
raise MCPConfigError(
|
|
101
|
+
f"servers.{server_name}.env.{key} exceeds maximum length {MAX_MCP_STRING_CHARS}"
|
|
102
|
+
)
|
|
103
|
+
assignments.append(f"{key}={item}")
|
|
104
|
+
|
|
105
|
+
for key, source_name in env_from.items():
|
|
106
|
+
if not isinstance(key, str) or not MCP_ENV_NAME_RE.fullmatch(key):
|
|
107
|
+
raise MCPConfigError(f"servers.{server_name}.env_from contains invalid env name")
|
|
108
|
+
if key in value:
|
|
109
|
+
raise MCPConfigError(
|
|
110
|
+
f"servers.{server_name}.env_from.{key} duplicates servers.{server_name}.env.{key}"
|
|
111
|
+
)
|
|
112
|
+
if not isinstance(source_name, str) or not MCP_ENV_NAME_RE.fullmatch(source_name):
|
|
113
|
+
raise MCPConfigError(f"servers.{server_name}.env_from.{key} must name a CI variable")
|
|
114
|
+
if source_name not in os.environ:
|
|
115
|
+
raise MCPConfigError(
|
|
116
|
+
f"servers.{server_name}.env_from.{key} references missing CI variable {source_name}"
|
|
117
|
+
)
|
|
118
|
+
secret_value = os.environ[source_name]
|
|
119
|
+
assignments.append(f"{key}={secret_value}")
|
|
120
|
+
if secret_value:
|
|
121
|
+
secret_values.append(secret_value)
|
|
122
|
+
|
|
123
|
+
return assignments, secret_values
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_header_name(name: Any, field: str) -> str:
|
|
127
|
+
"""Return an HTTP token header name or reject the input."""
|
|
128
|
+
|
|
129
|
+
if not isinstance(name, str) or not MCP_HEADER_NAME_RE.fullmatch(name):
|
|
130
|
+
raise MCPConfigError(f"{field} contains an invalid HTTP header name")
|
|
131
|
+
return name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _headers(value: Any, env_from: Any, server_name: str) -> tuple[dict[str, str], list[str]]:
|
|
135
|
+
"""Project bounded literal and environment-backed headers into OCR syntax."""
|
|
136
|
+
|
|
137
|
+
if value is None:
|
|
138
|
+
value = {}
|
|
139
|
+
if env_from is None:
|
|
140
|
+
env_from = {}
|
|
141
|
+
if not isinstance(value, dict):
|
|
142
|
+
raise MCPConfigError(f"servers.{server_name}.headers must be an object")
|
|
143
|
+
if not isinstance(env_from, dict):
|
|
144
|
+
raise MCPConfigError(f"servers.{server_name}.headers_from must be an object")
|
|
145
|
+
if len(value) + len(env_from) > MAX_MCP_HEADERS:
|
|
146
|
+
raise MCPConfigError(
|
|
147
|
+
f"servers.{server_name}.headers/headers_from exceed maximum item count "
|
|
148
|
+
f"{MAX_MCP_HEADERS}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
result: dict[str, str] = {}
|
|
152
|
+
normalized_names: set[str] = set()
|
|
153
|
+
secret_values: list[str] = []
|
|
154
|
+
for raw_name, item in value.items():
|
|
155
|
+
name = _validate_header_name(raw_name, f"servers.{server_name}.headers")
|
|
156
|
+
normalized = name.casefold()
|
|
157
|
+
if normalized in normalized_names:
|
|
158
|
+
raise MCPConfigError(f"servers.{server_name} contains duplicate HTTP header {name!r}")
|
|
159
|
+
normalized_names.add(normalized)
|
|
160
|
+
# Literal credentials would be copied into both CI input and OCR's on-disk
|
|
161
|
+
# config. Sensitive header families must cross this boundary only through
|
|
162
|
+
# an environment-variable reference.
|
|
163
|
+
if SENSITIVE_HEADER_NAME_RE.search(name):
|
|
164
|
+
raise MCPConfigError(
|
|
165
|
+
f"servers.{server_name}.headers.{name} is sensitive; use headers_from"
|
|
166
|
+
)
|
|
167
|
+
if not isinstance(item, str):
|
|
168
|
+
raise MCPConfigError(f"servers.{server_name}.headers.{name} must be a string")
|
|
169
|
+
if not item or len(item) > MAX_MCP_HEADER_VALUE_CHARS:
|
|
170
|
+
raise MCPConfigError(
|
|
171
|
+
f"servers.{server_name}.headers.{name} must be non-empty and at most "
|
|
172
|
+
f"{MAX_MCP_HEADER_VALUE_CHARS} characters"
|
|
173
|
+
)
|
|
174
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in item):
|
|
175
|
+
raise MCPConfigError(
|
|
176
|
+
f"servers.{server_name}.headers.{name} contains invalid control characters"
|
|
177
|
+
)
|
|
178
|
+
result[name] = item
|
|
179
|
+
|
|
180
|
+
for raw_name, source_name in env_from.items():
|
|
181
|
+
name = _validate_header_name(raw_name, f"servers.{server_name}.headers_from")
|
|
182
|
+
normalized = name.casefold()
|
|
183
|
+
if normalized in normalized_names:
|
|
184
|
+
raise MCPConfigError(f"servers.{server_name} contains duplicate HTTP header {name!r}")
|
|
185
|
+
normalized_names.add(normalized)
|
|
186
|
+
if not isinstance(source_name, str) or not MCP_ENV_NAME_RE.fullmatch(source_name):
|
|
187
|
+
raise MCPConfigError(
|
|
188
|
+
f"servers.{server_name}.headers_from.{name} must name a CI variable"
|
|
189
|
+
)
|
|
190
|
+
secret_value = os.environ.get(source_name)
|
|
191
|
+
if not secret_value:
|
|
192
|
+
raise MCPConfigError(
|
|
193
|
+
f"servers.{server_name}.headers_from.{name} references missing or empty CI "
|
|
194
|
+
f"variable {source_name}"
|
|
195
|
+
)
|
|
196
|
+
if len(secret_value) > MAX_MCP_HEADER_VALUE_CHARS:
|
|
197
|
+
raise MCPConfigError(
|
|
198
|
+
f"servers.{server_name}.headers_from.{name} references a CI variable "
|
|
199
|
+
f"exceeding {MAX_MCP_HEADER_VALUE_CHARS} characters"
|
|
200
|
+
)
|
|
201
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in secret_value):
|
|
202
|
+
raise MCPConfigError(
|
|
203
|
+
f"servers.{server_name}.headers_from.{name} references a CI variable "
|
|
204
|
+
"containing invalid HTTP control characters"
|
|
205
|
+
)
|
|
206
|
+
# OCR 1.8 expands $VARNAME when it creates the remote client. Keeping the
|
|
207
|
+
# reference avoids serializing the secret into config.json. Read the value
|
|
208
|
+
# once so missing credentials fail early and downstream errors can redact it.
|
|
209
|
+
result[name] = f"${source_name}"
|
|
210
|
+
secret_values.append(secret_value)
|
|
211
|
+
|
|
212
|
+
return result, secret_values
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _remote_url(value: Any, server_name: str) -> str:
|
|
216
|
+
"""Validate a bounded HTTPS Streamable HTTP endpoint for OCR."""
|
|
217
|
+
|
|
218
|
+
if not isinstance(value, str) or not value or len(value) > MAX_MCP_URL_CHARS:
|
|
219
|
+
raise MCPConfigError(
|
|
220
|
+
f"servers.{server_name}.url must be a non-empty HTTPS URL of at most "
|
|
221
|
+
f"{MAX_MCP_URL_CHARS} characters"
|
|
222
|
+
)
|
|
223
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
|
|
224
|
+
raise MCPConfigError(f"servers.{server_name}.url contains invalid control characters")
|
|
225
|
+
try:
|
|
226
|
+
parsed = urllib.parse.urlsplit(value)
|
|
227
|
+
port = parsed.port
|
|
228
|
+
except ValueError as exc:
|
|
229
|
+
raise MCPConfigError(f"servers.{server_name}.url is not a valid HTTPS URL") from exc
|
|
230
|
+
# OCR accepts plain HTTP, but the toolkit's CI contract is stricter because
|
|
231
|
+
# credentials can traverse this endpoint. Userinfo and fragments are rejected
|
|
232
|
+
# because they add leak-prone ambiguity; queries are accepted but never logged.
|
|
233
|
+
if (
|
|
234
|
+
parsed.scheme != "https"
|
|
235
|
+
or not parsed.hostname
|
|
236
|
+
or parsed.username is not None
|
|
237
|
+
or parsed.password is not None
|
|
238
|
+
or parsed.fragment
|
|
239
|
+
or (port is not None and not 1 <= port <= 65_535)
|
|
240
|
+
):
|
|
241
|
+
raise MCPConfigError(
|
|
242
|
+
f"servers.{server_name}.url must be absolute HTTPS without userinfo or fragment"
|
|
243
|
+
)
|
|
244
|
+
return value
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_mcp_servers(raw: str | None = None) -> list[MCPServerConfig]:
|
|
248
|
+
"""Return bounded stdio and remote configs from OCR_MCP_SERVERS_JSON."""
|
|
249
|
+
|
|
250
|
+
raw = os.environ.get("OCR_MCP_SERVERS_JSON", "") if raw is None else raw
|
|
251
|
+
raw = raw.strip()
|
|
252
|
+
if not raw:
|
|
253
|
+
return []
|
|
254
|
+
if len(raw.encode("utf-8")) > MAX_MCP_CONFIG_BYTES:
|
|
255
|
+
raise MCPConfigError(f"OCR_MCP_SERVERS_JSON exceeds {MAX_MCP_CONFIG_BYTES} bytes")
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
payload = json.loads(raw)
|
|
259
|
+
except json.JSONDecodeError as exc:
|
|
260
|
+
raise MCPConfigError(
|
|
261
|
+
"OCR_MCP_SERVERS_JSON is not valid JSON; check CI variable formatting"
|
|
262
|
+
) from exc
|
|
263
|
+
except RecursionError as exc:
|
|
264
|
+
raise MCPConfigError("OCR_MCP_SERVERS_JSON is too deeply nested") from exc
|
|
265
|
+
|
|
266
|
+
if not isinstance(payload, dict):
|
|
267
|
+
raise MCPConfigError("OCR_MCP_SERVERS_JSON top-level value must be an object")
|
|
268
|
+
if "servers" in payload:
|
|
269
|
+
if len(payload) != 1:
|
|
270
|
+
raise MCPConfigError(
|
|
271
|
+
"OCR_MCP_SERVERS_JSON cannot mix the servers envelope with named servers"
|
|
272
|
+
)
|
|
273
|
+
servers_value = payload["servers"]
|
|
274
|
+
if not isinstance(servers_value, list):
|
|
275
|
+
raise MCPConfigError("OCR_MCP_SERVERS_JSON.servers must be an array")
|
|
276
|
+
else:
|
|
277
|
+
servers_value = []
|
|
278
|
+
for name, value in payload.items():
|
|
279
|
+
if not isinstance(value, dict):
|
|
280
|
+
raise MCPConfigError(f"OCR MCP server {name!r} configuration must be an object")
|
|
281
|
+
if "name" in value and value["name"] != name:
|
|
282
|
+
raise MCPConfigError(f"OCR MCP server {name!r} must not declare a different name")
|
|
283
|
+
servers_value.append({**value, "name": name})
|
|
284
|
+
if len(servers_value) > MAX_MCP_SERVERS:
|
|
285
|
+
raise MCPConfigError(f"OCR_MCP_SERVERS_JSON has more than {MAX_MCP_SERVERS} servers")
|
|
286
|
+
|
|
287
|
+
seen: set[str] = set()
|
|
288
|
+
servers: list[MCPServerConfig] = []
|
|
289
|
+
for index, server in enumerate(servers_value):
|
|
290
|
+
if not isinstance(server, dict):
|
|
291
|
+
raise MCPConfigError(f"servers[{index}] must be an object")
|
|
292
|
+
enabled = server.get("enabled", True)
|
|
293
|
+
if not isinstance(enabled, bool):
|
|
294
|
+
raise MCPConfigError(f"servers[{index}].enabled must be a boolean")
|
|
295
|
+
if enabled is False:
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
name = server.get("name")
|
|
299
|
+
if not isinstance(name, str) or not MCP_NAME_RE.fullmatch(name):
|
|
300
|
+
raise MCPConfigError(f"servers[{index}].name is invalid")
|
|
301
|
+
if name in seen:
|
|
302
|
+
raise MCPConfigError(f"duplicate MCP server name {name!r}")
|
|
303
|
+
seen.add(name)
|
|
304
|
+
transport = server.get("type", "stdio")
|
|
305
|
+
if transport not in {"stdio", "remote"}:
|
|
306
|
+
raise MCPConfigError(f"servers.{name}.type must be 'stdio' or 'remote'")
|
|
307
|
+
common_fields = {"name", "type", "enabled", "tools", "setup"}
|
|
308
|
+
transport_fields = (
|
|
309
|
+
{"command", "args", "env", "env_from"}
|
|
310
|
+
if transport == "stdio"
|
|
311
|
+
else {"url", "headers", "headers_from"}
|
|
312
|
+
)
|
|
313
|
+
unknown_fields = set(server) - common_fields - transport_fields
|
|
314
|
+
if unknown_fields:
|
|
315
|
+
fields = ", ".join(sorted(unknown_fields))
|
|
316
|
+
raise MCPConfigError(f"servers.{name} has unsupported fields for {transport}: {fields}")
|
|
317
|
+
|
|
318
|
+
tools = _string_list(server.get("tools", []), f"servers.{name}.tools", limit=MAX_MCP_TOOLS)
|
|
319
|
+
setup = server.get("setup", "")
|
|
320
|
+
if not isinstance(setup, str):
|
|
321
|
+
raise MCPConfigError(f"servers.{name}.setup must be a string")
|
|
322
|
+
if len(setup) > MAX_MCP_SETUP_CHARS:
|
|
323
|
+
raise MCPConfigError(
|
|
324
|
+
f"servers.{name}.setup exceeds maximum length {MAX_MCP_SETUP_CHARS}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
command: str | None = None
|
|
328
|
+
url: str | None = None
|
|
329
|
+
args: list[str] = []
|
|
330
|
+
env: list[str] = []
|
|
331
|
+
headers: dict[str, str] = {}
|
|
332
|
+
secret_values: list[str] = []
|
|
333
|
+
if transport == "stdio":
|
|
334
|
+
command_value = server.get("command")
|
|
335
|
+
if not isinstance(command_value, str) or not command_value.strip():
|
|
336
|
+
raise MCPConfigError(f"servers.{name}.command must be a non-empty string")
|
|
337
|
+
command = command_value.strip()
|
|
338
|
+
if len(command) > MAX_MCP_STRING_CHARS:
|
|
339
|
+
raise MCPConfigError(
|
|
340
|
+
f"servers.{name}.command exceeds maximum length {MAX_MCP_STRING_CHARS}"
|
|
341
|
+
)
|
|
342
|
+
args = _string_list(server.get("args", []), f"servers.{name}.args", limit=MAX_MCP_ARGS)
|
|
343
|
+
env, secret_values = _env_assignments(server.get("env"), server.get("env_from"), name)
|
|
344
|
+
else:
|
|
345
|
+
url = _remote_url(server.get("url"), name)
|
|
346
|
+
headers, secret_values = _headers(
|
|
347
|
+
server.get("headers"), server.get("headers_from"), name
|
|
348
|
+
)
|
|
349
|
+
servers.append(
|
|
350
|
+
MCPServerConfig(
|
|
351
|
+
name=name,
|
|
352
|
+
transport=transport,
|
|
353
|
+
command=command,
|
|
354
|
+
url=url,
|
|
355
|
+
args=args,
|
|
356
|
+
tools=tools,
|
|
357
|
+
setup=setup,
|
|
358
|
+
env=env,
|
|
359
|
+
headers=headers,
|
|
360
|
+
secret_values=secret_values,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return servers
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _replace_configured_servers() -> bool:
|
|
368
|
+
"""Return whether generated servers replace, rather than merge with, OCR state."""
|
|
369
|
+
|
|
370
|
+
value = os.environ.get("OCR_MCP_REPLACE", "false").strip().lower()
|
|
371
|
+
if value in TRUE_VALUES:
|
|
372
|
+
return True
|
|
373
|
+
if value in FALSE_VALUES:
|
|
374
|
+
return False
|
|
375
|
+
raise MCPConfigError("OCR_MCP_REPLACE must be a boolean (true/false, 1/0, yes/no, or on/off)")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _redact_extra_values(text: str, values: list[str]) -> str:
|
|
379
|
+
"""Redact raw and URL-encoded secret variants from an error string."""
|
|
380
|
+
|
|
381
|
+
redacted = text
|
|
382
|
+
for value in values:
|
|
383
|
+
if value and len(value) >= 4:
|
|
384
|
+
for variant in {
|
|
385
|
+
value,
|
|
386
|
+
urllib.parse.quote(value, safe=""),
|
|
387
|
+
urllib.parse.quote_plus(value, safe=""),
|
|
388
|
+
}:
|
|
389
|
+
if variant:
|
|
390
|
+
redacted = redacted.replace(variant, "***")
|
|
391
|
+
return redacted
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def configure_mcp_servers() -> int:
|
|
395
|
+
"""Configure OCR MCP servers from CI and return a process exit code."""
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
servers = parse_mcp_servers()
|
|
399
|
+
replace = _replace_configured_servers()
|
|
400
|
+
except MCPConfigError as exc:
|
|
401
|
+
print(f"Invalid OCR MCP configuration: {redact_sensitive(str(exc))}", file=sys.stderr)
|
|
402
|
+
return 1
|
|
403
|
+
|
|
404
|
+
if not servers:
|
|
405
|
+
try:
|
|
406
|
+
if replace:
|
|
407
|
+
update_ocr_config({"mcp_servers": {}})
|
|
408
|
+
except OCRConfigError as exc:
|
|
409
|
+
print(
|
|
410
|
+
f"Failed to clear OCR MCP servers: {redact_sensitive(str(exc))}",
|
|
411
|
+
file=sys.stderr,
|
|
412
|
+
)
|
|
413
|
+
return 1
|
|
414
|
+
if replace:
|
|
415
|
+
print("OCR MCP servers: none configured; existing servers cleared")
|
|
416
|
+
else:
|
|
417
|
+
print("OCR MCP servers: none configured; existing servers preserved")
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
mcp_servers: dict[str, Any] = {}
|
|
421
|
+
secret_values: list[str] = []
|
|
422
|
+
for server in servers:
|
|
423
|
+
if server.transport == "stdio":
|
|
424
|
+
mcp_servers[server.name] = {
|
|
425
|
+
"type": "stdio",
|
|
426
|
+
"command": server.command,
|
|
427
|
+
"args": server.args,
|
|
428
|
+
"env": server.env,
|
|
429
|
+
"tools": server.tools,
|
|
430
|
+
"setup": server.setup,
|
|
431
|
+
}
|
|
432
|
+
else:
|
|
433
|
+
mcp_servers[server.name] = {
|
|
434
|
+
"type": "remote",
|
|
435
|
+
"url": server.url,
|
|
436
|
+
"headers": server.headers,
|
|
437
|
+
"tools": server.tools,
|
|
438
|
+
"setup": server.setup,
|
|
439
|
+
}
|
|
440
|
+
secret_values.extend(server.secret_values)
|
|
441
|
+
for assignment in server.env:
|
|
442
|
+
_env_name, _sep, env_value = assignment.partition("=")
|
|
443
|
+
if env_value:
|
|
444
|
+
secret_values.append(env_value)
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
if not replace:
|
|
448
|
+
# update_ocr_config replaces the complete mcp_servers object. Preserve
|
|
449
|
+
# unrelated entries here, before its atomic private-permission write.
|
|
450
|
+
existing = read_ocr_config().get("mcp_servers", {})
|
|
451
|
+
if existing is None:
|
|
452
|
+
existing = {}
|
|
453
|
+
if not isinstance(existing, dict):
|
|
454
|
+
raise OCRConfigError("OCR config mcp_servers value is not an object")
|
|
455
|
+
mcp_servers = {**existing, **mcp_servers}
|
|
456
|
+
update_ocr_config({"mcp_servers": mcp_servers})
|
|
457
|
+
except OCRConfigError as exc:
|
|
458
|
+
print(
|
|
459
|
+
"Failed to configure OCR MCP servers: "
|
|
460
|
+
f"{redact_sensitive(_redact_extra_values(str(exc), secret_values))}",
|
|
461
|
+
file=sys.stderr,
|
|
462
|
+
)
|
|
463
|
+
return 1
|
|
464
|
+
|
|
465
|
+
for server in servers:
|
|
466
|
+
if server.transport == "stdio":
|
|
467
|
+
detail = f"args={len(server.args)} env={len(server.env)}"
|
|
468
|
+
else:
|
|
469
|
+
detail = f"headers={len(server.headers)}"
|
|
470
|
+
print(
|
|
471
|
+
f"OCR MCP server configured: {server.name} type={server.transport} "
|
|
472
|
+
f"{detail} tools={len(server.tools)}"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
raise SystemExit(configure_mcp_servers())
|
|
@@ -62,6 +62,7 @@ from ocr_toolkit.posting.settings import (
|
|
|
62
62
|
post_mode,
|
|
63
63
|
strict_posting,
|
|
64
64
|
)
|
|
65
|
+
from ocr_toolkit.review_runner import read_stderr_excerpt
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
def finalize_posting(config: GitLabConfig, draft_note_ids: list[int]) -> bool:
|
|
@@ -709,33 +710,6 @@ def post_llm_provider_failure(
|
|
|
709
710
|
return 1
|
|
710
711
|
|
|
711
712
|
|
|
712
|
-
STDERR_PROBE_BYTES = 64 * 1024
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
def read_stderr_excerpt(stderr_path: Path, max_chars: int = 4000) -> str:
|
|
716
|
-
"""Read a bounded, redacted excerpt of an OCR stderr log.
|
|
717
|
-
|
|
718
|
-
Reads at most STDERR_PROBE_BYTES so a multi-megabyte stderr does
|
|
719
|
-
not stress the runner memory. Redaction runs on the full probe
|
|
720
|
-
BEFORE truncation so a secret bisected by the char limit is still
|
|
721
|
-
matched against its full environment-value form.
|
|
722
|
-
"""
|
|
723
|
-
|
|
724
|
-
if not stderr_path.exists():
|
|
725
|
-
return ""
|
|
726
|
-
|
|
727
|
-
try:
|
|
728
|
-
with stderr_path.open("rb") as handle:
|
|
729
|
-
chunk = handle.read(STDERR_PROBE_BYTES)
|
|
730
|
-
except OSError:
|
|
731
|
-
return ""
|
|
732
|
-
|
|
733
|
-
text = chunk.decode("utf-8", errors="replace").strip()
|
|
734
|
-
if not text:
|
|
735
|
-
return ""
|
|
736
|
-
return redact_sensitive(text)[:max_chars]
|
|
737
|
-
|
|
738
|
-
|
|
739
713
|
def post_parse_error(config: GitLabConfig, stderr_path: Path) -> int:
|
|
740
714
|
"""Post a safe error note when OCR output is not valid JSON.
|
|
741
715
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Run Open Code Review with private artifacts and safe CI diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from contextlib import ExitStack
|
|
10
|
+
from io import BufferedWriter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ocr_toolkit.common.redaction import redact_sensitive
|
|
14
|
+
|
|
15
|
+
STDERR_PROBE_BYTES = 64 * 1024
|
|
16
|
+
DEFAULT_DIAGNOSTIC_CHARS = 4_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReviewRunnerError(Exception):
|
|
20
|
+
"""The local OCR review process could not be started safely."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _open_private_artifact(path: Path, label: str) -> BufferedWriter:
|
|
24
|
+
"""Open one regular artifact without following a final-component symlink."""
|
|
25
|
+
|
|
26
|
+
flags = os.O_CREAT | os.O_WRONLY
|
|
27
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
28
|
+
# The pre-check gives a clear error; O_NOFOLLOW closes the replacement race
|
|
29
|
+
# between that check and open on platforms that expose the flag.
|
|
30
|
+
flags |= os.O_NOFOLLOW
|
|
31
|
+
if hasattr(os, "O_NONBLOCK"):
|
|
32
|
+
# Opening a pre-created FIFO for writing could otherwise block the CI job
|
|
33
|
+
# before the descriptor can be rejected as a non-artifact sink.
|
|
34
|
+
flags |= os.O_NONBLOCK
|
|
35
|
+
descriptor = -1
|
|
36
|
+
try:
|
|
37
|
+
descriptor = os.open(path, flags, 0o600)
|
|
38
|
+
file_stat = os.fstat(descriptor)
|
|
39
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
40
|
+
raise ReviewRunnerError(f"private {label} artifact must be a regular file: {path}")
|
|
41
|
+
if file_stat.st_nlink > 1:
|
|
42
|
+
raise ReviewRunnerError(f"private {label} artifact must not have hard links: {path}")
|
|
43
|
+
os.fchmod(descriptor, 0o600)
|
|
44
|
+
# Delay truncation until the descriptor has passed type and link checks.
|
|
45
|
+
os.ftruncate(descriptor, 0)
|
|
46
|
+
except ReviewRunnerError:
|
|
47
|
+
if descriptor >= 0:
|
|
48
|
+
os.close(descriptor)
|
|
49
|
+
raise
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
if descriptor >= 0:
|
|
52
|
+
os.close(descriptor)
|
|
53
|
+
raise ReviewRunnerError(f"could not open private {label} artifact: {exc}") from exc
|
|
54
|
+
return open(descriptor, "wb", closefd=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_stderr_excerpt(stderr_path: Path, max_chars: int = DEFAULT_DIAGNOSTIC_CHARS) -> str:
|
|
58
|
+
"""Read a bounded, redacted excerpt of an OCR stderr artifact."""
|
|
59
|
+
|
|
60
|
+
if not stderr_path.exists():
|
|
61
|
+
return ""
|
|
62
|
+
try:
|
|
63
|
+
with stderr_path.open("rb") as handle:
|
|
64
|
+
chunk = handle.read(STDERR_PROBE_BYTES)
|
|
65
|
+
except OSError:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
text = chunk.decode("utf-8", errors="replace").strip()
|
|
69
|
+
if not text:
|
|
70
|
+
return ""
|
|
71
|
+
# Redact before truncation so a secret crossing the display boundary is
|
|
72
|
+
# matched against its complete environment value or credential pattern.
|
|
73
|
+
return redact_sensitive(text)[:max_chars]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_review(result_path: Path, stderr_path: Path, ocr_args: list[str]) -> int:
|
|
77
|
+
"""Execute `ocr review`, retain private artifacts, and report safe failures."""
|
|
78
|
+
|
|
79
|
+
if not ocr_args:
|
|
80
|
+
raise ReviewRunnerError("at least one OCR review argument is required after --")
|
|
81
|
+
if result_path.absolute() == stderr_path.absolute():
|
|
82
|
+
raise ReviewRunnerError("result and stderr paths must be different")
|
|
83
|
+
for path, label in ((result_path, "result"), (stderr_path, "stderr")):
|
|
84
|
+
if path.is_symlink():
|
|
85
|
+
raise ReviewRunnerError(f"{label} path must not be a symlink: {path}")
|
|
86
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# Use explicit argv and file descriptors: OCR-controlled output never crosses
|
|
89
|
+
# a shell, while umask 077 keeps both artifacts private on shared runners.
|
|
90
|
+
previous_umask = os.umask(0o077)
|
|
91
|
+
try:
|
|
92
|
+
with ExitStack() as stack:
|
|
93
|
+
result_file = stack.enter_context(_open_private_artifact(result_path, "result"))
|
|
94
|
+
stderr_file = stack.enter_context(_open_private_artifact(stderr_path, "stderr"))
|
|
95
|
+
if os.path.samestat(os.fstat(result_file.fileno()), os.fstat(stderr_file.fileno())):
|
|
96
|
+
raise ReviewRunnerError("result and stderr paths must be different")
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
["ocr", "review", *ocr_args],
|
|
99
|
+
check=False,
|
|
100
|
+
stdin=subprocess.DEVNULL,
|
|
101
|
+
stdout=result_file,
|
|
102
|
+
stderr=stderr_file,
|
|
103
|
+
)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
raise ReviewRunnerError(f"could not execute OCR: {exc}") from exc
|
|
106
|
+
finally:
|
|
107
|
+
os.umask(previous_umask)
|
|
108
|
+
|
|
109
|
+
if completed.returncode != 0:
|
|
110
|
+
print(f"Open Code Review exited with code {completed.returncode}.", file=sys.stderr)
|
|
111
|
+
excerpt = read_stderr_excerpt(stderr_path)
|
|
112
|
+
if excerpt:
|
|
113
|
+
print("Safe OCR stderr excerpt:", file=sys.stderr)
|
|
114
|
+
print(excerpt, file=sys.stderr)
|
|
115
|
+
else:
|
|
116
|
+
print("OCR did not provide a readable stderr diagnostic.", file=sys.stderr)
|
|
117
|
+
return completed.returncode
|
|
@@ -1,257 +0,0 @@
|
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/__init__.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/__init__.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/language.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/common/markdown.py
RENAMED
|
File without changes
|
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/config_writer.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/configure.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/ansible.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/planner.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/render.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/context/repo.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/gitlab.py
RENAMED
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/markers.py
RENAMED
|
File without changes
|
|
File without changes
|
{open_code_review_toolkit-0.3.0 → open_code_review_toolkit-0.3.1}/src/ocr_toolkit/posting/result.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|