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,672 @@
|
|
|
1
|
+
"""Repository, filesystem, command, and glob helpers for OCR context generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import heapq
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ocr_toolkit.context.settings import (
|
|
18
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
19
|
+
MAX_BACKGROUND_SECTION_ITEMS,
|
|
20
|
+
inline_code,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
ROOT = Path.cwd()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CommandResult:
|
|
28
|
+
"""Small command execution result used for optional local tool detection."""
|
|
29
|
+
|
|
30
|
+
stdout: str
|
|
31
|
+
stderr: str
|
|
32
|
+
returncode: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_EXCLUDE_DIRS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
".git",
|
|
38
|
+
".DS_Store",
|
|
39
|
+
".review-context",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"vendor",
|
|
42
|
+
".venv",
|
|
43
|
+
"venv",
|
|
44
|
+
"__pycache__",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
LOCAL_GUIDANCE_STATUS_PATHS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"PR_REVIEW.md",
|
|
51
|
+
"AGENTS.md",
|
|
52
|
+
"AGENTS.MD",
|
|
53
|
+
"CLAUDE.md",
|
|
54
|
+
"CLAUDE.MD",
|
|
55
|
+
".cursorrules",
|
|
56
|
+
".github/copilot-instructions.md",
|
|
57
|
+
".opencodereview/accepted-decisions.md",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_command(cmd: Sequence[str], timeout: int = 10) -> CommandResult:
|
|
63
|
+
"""Run a local command and return captured output without raising.
|
|
64
|
+
|
|
65
|
+
Commands are used only for optional local version discovery, for example
|
|
66
|
+
`ansible --version` when the binary is available in the CI image.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
proc = subprocess.run(
|
|
71
|
+
list(cmd),
|
|
72
|
+
cwd=ROOT,
|
|
73
|
+
text=True,
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
errors="replace",
|
|
76
|
+
stdout=subprocess.PIPE,
|
|
77
|
+
stderr=subprocess.PIPE,
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
check=False,
|
|
80
|
+
)
|
|
81
|
+
return CommandResult(proc.stdout, proc.stderr, proc.returncode)
|
|
82
|
+
except subprocess.TimeoutExpired as exc:
|
|
83
|
+
# Surface timeouts: a silent empty result here turns into a
|
|
84
|
+
# missing background section downstream and is much harder to
|
|
85
|
+
# diagnose than a one-line log entry.
|
|
86
|
+
print(
|
|
87
|
+
f"run_command timeout after {timeout}s: {' '.join(str(c) for c in cmd)}",
|
|
88
|
+
file=sys.stderr,
|
|
89
|
+
)
|
|
90
|
+
return CommandResult("", str(exc), 124)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
print(
|
|
93
|
+
f"run_command failed: {' '.join(str(c) for c in cmd)}: {exc}",
|
|
94
|
+
file=sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
return CommandResult("", str(exc), 127)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_repo_file(path: Path) -> Path | None:
|
|
100
|
+
"""Return a resolved regular file path only when it stays inside ROOT."""
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
root = ROOT.resolve()
|
|
104
|
+
resolved = path.resolve(strict=True)
|
|
105
|
+
resolved.relative_to(root)
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
return resolved if resolved.is_file() else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def safe_repo_match(path: Path, *, files_only: bool) -> Path | None:
|
|
113
|
+
"""Return a repository-local match only when it is safe to count."""
|
|
114
|
+
|
|
115
|
+
if has_symlink_component(path):
|
|
116
|
+
return None
|
|
117
|
+
if files_only:
|
|
118
|
+
return resolve_repo_file(path)
|
|
119
|
+
try:
|
|
120
|
+
root = ROOT.resolve()
|
|
121
|
+
resolved = path.resolve(strict=True)
|
|
122
|
+
resolved.relative_to(root)
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
return None
|
|
125
|
+
return resolved
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def has_symlink_component(path: Path) -> bool:
|
|
129
|
+
"""Return whether any path component under ROOT is a symlink."""
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
rel_path = path.relative_to(ROOT)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
current = ROOT
|
|
137
|
+
for part in rel_path.parts:
|
|
138
|
+
current = current / part
|
|
139
|
+
if current.is_symlink():
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def trusted_guidance_file(rel_path: str) -> Path | None:
|
|
145
|
+
"""Return a trusted guidance file, or None when unsafe or missing."""
|
|
146
|
+
|
|
147
|
+
path = ROOT / rel_path
|
|
148
|
+
if has_symlink_component(path):
|
|
149
|
+
print(
|
|
150
|
+
f"Skipping symlinked guidance path {rel_path}; cannot trust its content for reviewer guidance.",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
return resolve_repo_file(path)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_output_path(value: str) -> Path | None:
|
|
158
|
+
"""Resolve an output path limited to the repo or system temp directory."""
|
|
159
|
+
|
|
160
|
+
root_lexical = ROOT.absolute()
|
|
161
|
+
root = ROOT.resolve()
|
|
162
|
+
requested = Path(value)
|
|
163
|
+
logical_output = requested if requested.is_absolute() else root_lexical / requested
|
|
164
|
+
logical_output = logical_output.absolute()
|
|
165
|
+
temp_lexical = Path(tempfile.gettempdir()).absolute()
|
|
166
|
+
allowed_roots = [(root_lexical, root), (root, root)]
|
|
167
|
+
temp_resolved = temp_lexical.resolve()
|
|
168
|
+
allowed_roots.extend([(temp_lexical, temp_resolved), (temp_resolved, temp_resolved)])
|
|
169
|
+
tmp_path = Path("/tmp")
|
|
170
|
+
if tmp_path.exists():
|
|
171
|
+
tmp_lexical = tmp_path.absolute()
|
|
172
|
+
tmp_resolved = tmp_path.resolve()
|
|
173
|
+
allowed_roots.extend([(tmp_lexical, tmp_resolved), (tmp_resolved, tmp_resolved)])
|
|
174
|
+
|
|
175
|
+
for lexical_root, resolved_root in allowed_roots:
|
|
176
|
+
try:
|
|
177
|
+
logical_relative = logical_output.relative_to(lexical_root)
|
|
178
|
+
except ValueError:
|
|
179
|
+
continue
|
|
180
|
+
current = lexical_root
|
|
181
|
+
for part in logical_relative.parts:
|
|
182
|
+
current = current / part
|
|
183
|
+
if current.is_symlink():
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
output = logical_output.resolve()
|
|
187
|
+
output.relative_to(resolved_root)
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
return None
|
|
190
|
+
return output
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_text(path: Path, max_bytes: int = DEFAULT_MAX_FILE_BYTES) -> str:
|
|
196
|
+
"""Read a UTF-8-ish repository text file defensively with a byte limit."""
|
|
197
|
+
|
|
198
|
+
safe_path = resolve_repo_file(path)
|
|
199
|
+
if safe_path is None:
|
|
200
|
+
return ""
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
with safe_path.open("rb") as handle:
|
|
204
|
+
read_limit = max(0, max_bytes)
|
|
205
|
+
data = handle.read(read_limit)
|
|
206
|
+
truncated = read_limit > 0 and bool(handle.read(1))
|
|
207
|
+
except OSError:
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
text = data.decode("utf-8", errors="replace")
|
|
211
|
+
if truncated:
|
|
212
|
+
text = text.rstrip() + f"\n# [truncated after {read_limit} bytes]\n"
|
|
213
|
+
return text
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def path_exists(path: str) -> bool:
|
|
217
|
+
"""Return whether a repository-relative regular file exists."""
|
|
218
|
+
|
|
219
|
+
return resolve_repo_file(ROOT / path) is not None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def inline_ci_value(
|
|
223
|
+
env_name: str,
|
|
224
|
+
*,
|
|
225
|
+
fallback_git_args: Sequence[str] | None = None,
|
|
226
|
+
use_default_branch: bool = False,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Render CI metadata, marking local fallbacks explicitly."""
|
|
229
|
+
|
|
230
|
+
value = os.environ.get(env_name, "").strip()
|
|
231
|
+
if value:
|
|
232
|
+
return inline_code(value)
|
|
233
|
+
|
|
234
|
+
fallback_value = ""
|
|
235
|
+
if use_default_branch:
|
|
236
|
+
fallback_value = local_default_branch()
|
|
237
|
+
elif fallback_git_args is not None:
|
|
238
|
+
fallback_value = git_output(fallback_git_args)
|
|
239
|
+
|
|
240
|
+
if fallback_value:
|
|
241
|
+
return f"{inline_code(fallback_value)} _(local fallback; {env_name} unset)_"
|
|
242
|
+
return f"_(not provided; {env_name} unset)_"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def git_output(args: Sequence[str], timeout: int = 10) -> str:
|
|
246
|
+
"""Return stripped git stdout or an empty string on failure."""
|
|
247
|
+
|
|
248
|
+
result = run_command(["git", *args], timeout=timeout)
|
|
249
|
+
if result.returncode != 0:
|
|
250
|
+
return ""
|
|
251
|
+
return result.stdout.strip()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def local_default_branch() -> str:
|
|
255
|
+
"""Return the local default branch ref when GitLab CI metadata is absent."""
|
|
256
|
+
|
|
257
|
+
ref = git_output(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
|
|
258
|
+
if ref:
|
|
259
|
+
return ref
|
|
260
|
+
|
|
261
|
+
default_branch = os.environ.get("CI_DEFAULT_BRANCH", "").strip()
|
|
262
|
+
if default_branch:
|
|
263
|
+
return default_branch
|
|
264
|
+
|
|
265
|
+
return ""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def bounded_rel_glob(
|
|
269
|
+
patterns: Iterable[str],
|
|
270
|
+
limit: int = MAX_BACKGROUND_SECTION_ITEMS,
|
|
271
|
+
*,
|
|
272
|
+
files_only: bool = False,
|
|
273
|
+
exclude_dirs: Iterable[str] | None = None,
|
|
274
|
+
) -> list[str]:
|
|
275
|
+
"""Find bounded repository-relative glob matches without materializing all matches.
|
|
276
|
+
|
|
277
|
+
Heavy vendored/build directories are excluded by default so a single
|
|
278
|
+
`**/...` pattern does not walk through `node_modules`, `vendor`, etc.
|
|
279
|
+
Pass an explicit ``exclude_dirs`` (including an empty iterable) to
|
|
280
|
+
override the default set.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
if limit <= 0:
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
result: list[str] = []
|
|
287
|
+
seen: set[str] = set()
|
|
288
|
+
excluded_parts = DEFAULT_EXCLUDE_DIRS if exclude_dirs is None else frozenset(exclude_dirs)
|
|
289
|
+
|
|
290
|
+
for pattern in patterns:
|
|
291
|
+
remaining = limit - len(result)
|
|
292
|
+
if remaining <= 0:
|
|
293
|
+
return result
|
|
294
|
+
paths = (
|
|
295
|
+
iter_plain_glob(
|
|
296
|
+
pattern,
|
|
297
|
+
excluded_parts,
|
|
298
|
+
limit=remaining,
|
|
299
|
+
files_only=files_only,
|
|
300
|
+
skip_rel_paths=seen,
|
|
301
|
+
)
|
|
302
|
+
if "**" not in pattern
|
|
303
|
+
else iter_repo_glob(
|
|
304
|
+
pattern,
|
|
305
|
+
excluded_parts,
|
|
306
|
+
limit=remaining,
|
|
307
|
+
files_only=files_only,
|
|
308
|
+
skip_rel_paths=seen,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
for path in paths:
|
|
312
|
+
rel_path = path.relative_to(ROOT)
|
|
313
|
+
rel = str(rel_path)
|
|
314
|
+
if rel in seen:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
seen.add(rel)
|
|
318
|
+
result.append(rel)
|
|
319
|
+
if len(result) >= limit:
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def iter_repo_glob(
|
|
326
|
+
pattern: str,
|
|
327
|
+
excluded_parts: frozenset[str],
|
|
328
|
+
limit: int | None = None,
|
|
329
|
+
*,
|
|
330
|
+
files_only: bool = False,
|
|
331
|
+
skip_rel_paths: set[str] | None = None,
|
|
332
|
+
) -> Iterable[Path]:
|
|
333
|
+
"""Yield repository paths for a glob pattern while pruning excluded dirs."""
|
|
334
|
+
|
|
335
|
+
if "**" not in pattern:
|
|
336
|
+
for path in iter_plain_glob(
|
|
337
|
+
pattern,
|
|
338
|
+
excluded_parts,
|
|
339
|
+
limit=limit,
|
|
340
|
+
files_only=files_only,
|
|
341
|
+
skip_rel_paths=skip_rel_paths,
|
|
342
|
+
):
|
|
343
|
+
yield path
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
pattern_parts = tuple(part for part in pattern.split("/") if part)
|
|
347
|
+
|
|
348
|
+
@cache
|
|
349
|
+
def matches(parts: tuple[str, ...], pattern_index: int = 0, path_index: int = 0) -> bool:
|
|
350
|
+
if pattern_index == len(pattern_parts):
|
|
351
|
+
return path_index == len(parts)
|
|
352
|
+
segment = pattern_parts[pattern_index]
|
|
353
|
+
if segment == "**":
|
|
354
|
+
return matches(parts, pattern_index + 1, path_index) or (
|
|
355
|
+
path_index < len(parts) and matches(parts, pattern_index, path_index + 1)
|
|
356
|
+
)
|
|
357
|
+
return (
|
|
358
|
+
path_index < len(parts)
|
|
359
|
+
and fnmatch.fnmatchcase(parts[path_index], segment)
|
|
360
|
+
and matches(parts, pattern_index + 1, path_index + 1)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
yielded = 0
|
|
364
|
+
for dirpath, dirnames, filenames in os.walk(ROOT, topdown=True):
|
|
365
|
+
if limit is not None and yielded >= limit:
|
|
366
|
+
return
|
|
367
|
+
rel_dir = Path(dirpath).relative_to(ROOT)
|
|
368
|
+
if excluded_parts and any(part in excluded_parts for part in rel_dir.parts):
|
|
369
|
+
dirnames[:] = []
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
dirnames[:] = sorted(dirnames)
|
|
373
|
+
filenames = sorted(filenames)
|
|
374
|
+
|
|
375
|
+
if excluded_parts:
|
|
376
|
+
dirnames[:] = [dirname for dirname in dirnames if dirname not in excluded_parts]
|
|
377
|
+
|
|
378
|
+
for name in [*dirnames, *filenames]:
|
|
379
|
+
path = Path(dirpath) / name
|
|
380
|
+
if safe_repo_match(path, files_only=files_only) is None:
|
|
381
|
+
continue
|
|
382
|
+
rel = path.relative_to(ROOT).as_posix()
|
|
383
|
+
if skip_rel_paths and rel in skip_rel_paths:
|
|
384
|
+
continue
|
|
385
|
+
if matches(tuple(Path(rel).parts)):
|
|
386
|
+
yielded += 1
|
|
387
|
+
yield path
|
|
388
|
+
if limit is not None and yielded >= limit:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def iter_plain_glob(
|
|
393
|
+
pattern: str,
|
|
394
|
+
excluded_parts: frozenset[str],
|
|
395
|
+
limit: int | None = None,
|
|
396
|
+
*,
|
|
397
|
+
files_only: bool = False,
|
|
398
|
+
skip_rel_paths: set[str] | None = None,
|
|
399
|
+
) -> Iterable[Path]:
|
|
400
|
+
"""Yield non-recursive glob matches in sorted order without ROOT.glob fanout."""
|
|
401
|
+
|
|
402
|
+
parts = pattern.split("/")
|
|
403
|
+
emitted = 0
|
|
404
|
+
|
|
405
|
+
def emit(path: Path) -> Iterable[Path]:
|
|
406
|
+
nonlocal emitted
|
|
407
|
+
rel = path.relative_to(ROOT).as_posix()
|
|
408
|
+
if excluded_parts and any(part in excluded_parts for part in Path(rel).parts):
|
|
409
|
+
return
|
|
410
|
+
if skip_rel_paths and rel in skip_rel_paths:
|
|
411
|
+
return
|
|
412
|
+
if safe_repo_match(path, files_only=files_only) is None:
|
|
413
|
+
return
|
|
414
|
+
if limit is not None and emitted >= limit:
|
|
415
|
+
return
|
|
416
|
+
emitted += 1
|
|
417
|
+
yield path
|
|
418
|
+
|
|
419
|
+
def walk(base: Path, index: int) -> Iterable[Path]:
|
|
420
|
+
if index >= len(parts):
|
|
421
|
+
if base.exists() and not base.is_symlink():
|
|
422
|
+
yield from emit(base)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
part = parts[index]
|
|
426
|
+
if not part:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
if any(char in part for char in "*?["):
|
|
430
|
+
try:
|
|
431
|
+
candidate_entries = []
|
|
432
|
+
final_segment = index == len(parts) - 1
|
|
433
|
+
for entry in base.iterdir():
|
|
434
|
+
if entry.is_dir() and entry.is_symlink():
|
|
435
|
+
continue
|
|
436
|
+
if excluded_parts and entry.name in excluded_parts:
|
|
437
|
+
continue
|
|
438
|
+
if not fnmatch.fnmatchcase(entry.name, part):
|
|
439
|
+
continue
|
|
440
|
+
if final_segment and safe_repo_match(entry, files_only=files_only) is None:
|
|
441
|
+
continue
|
|
442
|
+
candidate_entries.append(entry)
|
|
443
|
+
|
|
444
|
+
key = lambda item: item.relative_to(ROOT).as_posix()
|
|
445
|
+
if final_segment and limit is not None:
|
|
446
|
+
remaining = max(0, limit - emitted)
|
|
447
|
+
if skip_rel_paths:
|
|
448
|
+
candidate_entries = [
|
|
449
|
+
entry
|
|
450
|
+
for entry in candidate_entries
|
|
451
|
+
if entry.relative_to(ROOT).as_posix() not in skip_rel_paths
|
|
452
|
+
]
|
|
453
|
+
entries = heapq.nsmallest(remaining, candidate_entries, key=key)
|
|
454
|
+
else:
|
|
455
|
+
entries = sorted(candidate_entries, key=key)
|
|
456
|
+
except OSError:
|
|
457
|
+
return
|
|
458
|
+
for entry in entries:
|
|
459
|
+
if limit is not None and emitted >= limit:
|
|
460
|
+
return
|
|
461
|
+
yield from walk(entry, index + 1)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if excluded_parts and part in excluded_parts:
|
|
465
|
+
return
|
|
466
|
+
if part == "..":
|
|
467
|
+
return
|
|
468
|
+
next_base = base / part
|
|
469
|
+
if next_base.is_dir() and next_base.is_symlink():
|
|
470
|
+
return
|
|
471
|
+
yield from walk(next_base, index + 1)
|
|
472
|
+
|
|
473
|
+
yield from walk(ROOT, 0)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def rel_glob(patterns: Iterable[str], limit: int = MAX_BACKGROUND_SECTION_ITEMS) -> list[str]:
|
|
477
|
+
"""Find repository-relative paths matching any of the provided glob patterns."""
|
|
478
|
+
|
|
479
|
+
return bounded_rel_glob(patterns, limit=limit)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def parse_git_name_only_output(result: CommandResult) -> list[str] | None:
|
|
483
|
+
"""Parse `git diff --name-only -z` output or return None on failure."""
|
|
484
|
+
|
|
485
|
+
if result.returncode != 0:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
return [path for path in result.stdout.split("\0") if path]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def parse_git_status_porcelain_output(result: CommandResult) -> list[str] | None:
|
|
492
|
+
"""Parse `git status --porcelain -z` paths or return None on failure."""
|
|
493
|
+
|
|
494
|
+
if result.returncode != 0:
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
paths: list[str] = []
|
|
498
|
+
parts = [part for part in result.stdout.split("\0") if part]
|
|
499
|
+
index = 0
|
|
500
|
+
while index < len(parts):
|
|
501
|
+
entry = parts[index]
|
|
502
|
+
index += 1
|
|
503
|
+
if len(entry) < 4:
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
status = entry[:2]
|
|
507
|
+
path = entry[3:]
|
|
508
|
+
if path:
|
|
509
|
+
paths.append(path)
|
|
510
|
+
|
|
511
|
+
if "R" in status or "C" in status:
|
|
512
|
+
# With `-z`, rename/copy entries are followed by the original path.
|
|
513
|
+
# The new path above is what matters for guidance self-review checks.
|
|
514
|
+
index += 1
|
|
515
|
+
|
|
516
|
+
return paths
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def local_changed_files() -> list[str] | None:
|
|
520
|
+
"""Return local branch changed files when GitLab MR env is unavailable."""
|
|
521
|
+
|
|
522
|
+
source_sha = os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "").strip()
|
|
523
|
+
if source_sha and not set(source_sha) <= {"0"}:
|
|
524
|
+
head = source_sha
|
|
525
|
+
else:
|
|
526
|
+
head = os.environ.get("CI_COMMIT_SHA", "HEAD")
|
|
527
|
+
base_refs = [
|
|
528
|
+
ref
|
|
529
|
+
for ref in (
|
|
530
|
+
os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", ""),
|
|
531
|
+
local_default_branch(),
|
|
532
|
+
"origin/master",
|
|
533
|
+
)
|
|
534
|
+
if ref
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
seen_bases: set[str] = set()
|
|
538
|
+
for base_ref in base_refs:
|
|
539
|
+
if base_ref in seen_bases:
|
|
540
|
+
continue
|
|
541
|
+
seen_bases.add(base_ref)
|
|
542
|
+
|
|
543
|
+
merge_base = run_command(["git", "merge-base", base_ref, head], timeout=30)
|
|
544
|
+
if merge_base.returncode != 0 or not merge_base.stdout.strip():
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
# Local runs should reflect tracked working-tree edits as well as
|
|
548
|
+
# committed branch changes. CI uses the MR-specific path above.
|
|
549
|
+
result = run_command(
|
|
550
|
+
["git", "diff", "--name-only", "-z", merge_base.stdout.strip()],
|
|
551
|
+
timeout=30,
|
|
552
|
+
)
|
|
553
|
+
files = parse_git_name_only_output(result)
|
|
554
|
+
if files is not None:
|
|
555
|
+
status = run_command(
|
|
556
|
+
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
557
|
+
timeout=30,
|
|
558
|
+
)
|
|
559
|
+
status_files = parse_git_status_porcelain_output(status)
|
|
560
|
+
if status_files is not None:
|
|
561
|
+
seen = set(files)
|
|
562
|
+
for path in status_files:
|
|
563
|
+
if path not in LOCAL_GUIDANCE_STATUS_PATHS:
|
|
564
|
+
continue
|
|
565
|
+
if path in seen:
|
|
566
|
+
continue
|
|
567
|
+
seen.add(path)
|
|
568
|
+
files.append(path)
|
|
569
|
+
print(
|
|
570
|
+
f"Using local changed-files fallback against {base_ref}; GitLab MR env is unavailable.",
|
|
571
|
+
file=sys.stderr,
|
|
572
|
+
)
|
|
573
|
+
return files
|
|
574
|
+
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def changed_files() -> list[str] | None:
|
|
579
|
+
"""Return files changed in the current GitLab merge request.
|
|
580
|
+
|
|
581
|
+
Prefer GitLab's MR diff base SHA because it matches the merge request diff
|
|
582
|
+
more closely than a plain two-dot diff against the current target branch.
|
|
583
|
+
|
|
584
|
+
Returns ``None`` when no diff strategy succeeded — distinct from an
|
|
585
|
+
empty list (which means "successfully computed, no files changed").
|
|
586
|
+
Callers downstream use the None state to fail closed on guidance/
|
|
587
|
+
accepted-decisions inclusion so an MR cannot self-whitelist by making
|
|
588
|
+
git introspection fail.
|
|
589
|
+
"""
|
|
590
|
+
|
|
591
|
+
target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
|
|
592
|
+
source_sha = os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "").strip()
|
|
593
|
+
if source_sha and not set(source_sha) <= {"0"}:
|
|
594
|
+
head = source_sha
|
|
595
|
+
else:
|
|
596
|
+
if os.environ.get("CI_MERGE_REQUEST_EVENT_TYPE") in {
|
|
597
|
+
"merged_result",
|
|
598
|
+
"merge_train",
|
|
599
|
+
}:
|
|
600
|
+
print(
|
|
601
|
+
"CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is unavailable in a merged-result/merge-train pipeline; "
|
|
602
|
+
"refusing to diff the synthetic pipeline commit for review context.",
|
|
603
|
+
file=sys.stderr,
|
|
604
|
+
)
|
|
605
|
+
return None
|
|
606
|
+
head = os.environ.get("CI_COMMIT_SHA", "HEAD")
|
|
607
|
+
diff_base = os.environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA")
|
|
608
|
+
|
|
609
|
+
if not diff_base and not target:
|
|
610
|
+
local_files = local_changed_files()
|
|
611
|
+
if local_files is not None:
|
|
612
|
+
return local_files
|
|
613
|
+
|
|
614
|
+
diff_bases: list[str] = []
|
|
615
|
+
|
|
616
|
+
if diff_base:
|
|
617
|
+
diff_bases.append(diff_base)
|
|
618
|
+
|
|
619
|
+
if target:
|
|
620
|
+
merge_base = run_command(["git", "merge-base", f"origin/{target}", head], timeout=30)
|
|
621
|
+
if merge_base.returncode == 0 and merge_base.stdout.strip():
|
|
622
|
+
candidate = merge_base.stdout.strip()
|
|
623
|
+
if candidate not in diff_bases:
|
|
624
|
+
diff_bases.append(candidate)
|
|
625
|
+
|
|
626
|
+
for base in diff_bases:
|
|
627
|
+
result = run_command(["git", "diff", "--name-only", "-z", base, head], timeout=30)
|
|
628
|
+
files = parse_git_name_only_output(result)
|
|
629
|
+
if files is not None:
|
|
630
|
+
return files
|
|
631
|
+
|
|
632
|
+
print(
|
|
633
|
+
f"Failed to compute changed files with diff base {base}; trying next strategy.",
|
|
634
|
+
file=sys.stderr,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# No HEAD~1 fallback on purpose: on a multi-commit MR it silently
|
|
638
|
+
# returns only the last commit's files, hiding the failure. Surface
|
|
639
|
+
# the error instead.
|
|
640
|
+
print(
|
|
641
|
+
"Failed to compute changed files for MR; downstream code will "
|
|
642
|
+
"treat guidance and accepted-decisions as unavailable to avoid "
|
|
643
|
+
"self-whitelisting an unknown change set.",
|
|
644
|
+
file=sys.stderr,
|
|
645
|
+
)
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def rel_glob_files(
|
|
650
|
+
patterns: Iterable[str],
|
|
651
|
+
limit: int = MAX_BACKGROUND_SECTION_ITEMS,
|
|
652
|
+
exclude_dirs: Iterable[str] | None = None,
|
|
653
|
+
) -> list[str]:
|
|
654
|
+
"""Find repository-relative files matching any of the provided glob patterns."""
|
|
655
|
+
|
|
656
|
+
return bounded_rel_glob(
|
|
657
|
+
patterns,
|
|
658
|
+
limit=limit,
|
|
659
|
+
files_only=True,
|
|
660
|
+
exclude_dirs=exclude_dirs,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def tool_version(binary: str, args: Sequence[str]) -> list[str]:
|
|
665
|
+
"""Return the first few version lines for an available local binary."""
|
|
666
|
+
|
|
667
|
+
if not shutil.which(binary):
|
|
668
|
+
return []
|
|
669
|
+
result = run_command([binary, *args], timeout=10)
|
|
670
|
+
if not result.stdout:
|
|
671
|
+
return []
|
|
672
|
+
return result.stdout.splitlines()[:8]
|