gitfence 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.
- fence/__init__.py +3 -0
- fence/anchors/__init__.py +0 -0
- fence/anchors/fingerprint.py +69 -0
- fence/check/__init__.py +0 -0
- fence/check/classify.py +84 -0
- fence/check/diff.py +127 -0
- fence/check/engine.py +263 -0
- fence/cli.py +576 -0
- fence/config.py +209 -0
- fence/context.py +77 -0
- fence/decisions/__init__.py +0 -0
- fence/decisions/build.py +579 -0
- fence/decisions/model.py +117 -0
- fence/decisions/obsolete.py +121 -0
- fence/decisions/reasons.py +150 -0
- fence/decisions/relevance.py +146 -0
- fence/diffing/__init__.py +0 -0
- fence/diffing/ranges.py +25 -0
- fence/diffing/symbols.py +21 -0
- fence/eval/__init__.py +0 -0
- fence/eval/backtest.py +271 -0
- fence/git/__init__.py +0 -0
- fence/git/blame.py +219 -0
- fence/git/history.py +171 -0
- fence/git/log.py +60 -0
- fence/git/runner.py +248 -0
- fence/github/__init__.py +0 -0
- fence/github/client.py +122 -0
- fence/github/comment.py +50 -0
- fence/github/sync.py +123 -0
- fence/globs.py +43 -0
- fence/index/__init__.py +0 -0
- fence/index/formatting.py +53 -0
- fence/index/ghosts.py +143 -0
- fence/index/indexer.py +219 -0
- fence/index/store.py +389 -0
- fence/lang/__init__.py +118 -0
- fence/lang/python.py +67 -0
- fence/lang/treesitter.py +165 -0
- fence/mcp/__init__.py +0 -0
- fence/mcp/server.py +306 -0
- fence/policy/__init__.py +0 -0
- fence/policy/acks.py +88 -0
- fence/render.py +287 -0
- fence/report.py +103 -0
- fence/scan.py +50 -0
- fence/scoring/__init__.py +0 -0
- fence/scoring/calibration.py +133 -0
- fence/scoring/strength.py +198 -0
- fence/signals/__init__.py +0 -0
- fence/signals/catalog.py +161 -0
- fence/signals/code.py +69 -0
- fence/text.py +43 -0
- fence/trace.py +65 -0
- gitfence-0.1.0.dist-info/METADATA +389 -0
- gitfence-0.1.0.dist-info/RECORD +59 -0
- gitfence-0.1.0.dist-info/WHEEL +4 -0
- gitfence-0.1.0.dist-info/entry_points.txt +3 -0
- gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/git/log.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`git log -L` for walking further back than blame when a commit explains nothing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fence.git.runner import GitError, Repo, UnsafeInput
|
|
8
|
+
|
|
9
|
+
DEFAULT_MAX_COUNT = 20
|
|
10
|
+
MAX_COUNT_LIMIT = 200
|
|
11
|
+
|
|
12
|
+
_FIELD = "\x1f"
|
|
13
|
+
_RECORD = "\x1e"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LogCommit:
|
|
18
|
+
sha: str
|
|
19
|
+
author: str
|
|
20
|
+
author_mail: str
|
|
21
|
+
date: str # strict ISO 8601 author date
|
|
22
|
+
subject: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def log_line_range(
|
|
26
|
+
repo: Repo, rev: str, path: str, start: int, end: int, *, max_count: int = DEFAULT_MAX_COUNT
|
|
27
|
+
) -> list[LogCommit]:
|
|
28
|
+
"""Commits that touched lines `start`..`end` of `path` as of `rev`, newest first."""
|
|
29
|
+
sha = repo.verify_ref(rev)
|
|
30
|
+
rel = repo.safe_relpath(path)
|
|
31
|
+
if start < 1 or end < start:
|
|
32
|
+
raise UnsafeInput(f"bad line range: {start},{end}")
|
|
33
|
+
if not 1 <= max_count <= MAX_COUNT_LIMIT:
|
|
34
|
+
raise UnsafeInput(f"max_count must be 1..{MAX_COUNT_LIMIT}")
|
|
35
|
+
out = repo.run(
|
|
36
|
+
[
|
|
37
|
+
"log",
|
|
38
|
+
f"-L{start},{end}:{rel}",
|
|
39
|
+
"-s", # metadata only; the patch text is never needed here
|
|
40
|
+
"--no-color",
|
|
41
|
+
"--no-ext-diff",
|
|
42
|
+
f"--max-count={max_count}",
|
|
43
|
+
f"--format=%H{_FIELD}%an{_FIELD}%ae{_FIELD}%aI{_FIELD}%s{_RECORD}",
|
|
44
|
+
sha,
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
return parse_log(out)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_log(output: str) -> list[LogCommit]:
|
|
51
|
+
commits: list[LogCommit] = []
|
|
52
|
+
for record in output.split(_RECORD):
|
|
53
|
+
record = record.strip("\n")
|
|
54
|
+
if not record:
|
|
55
|
+
continue
|
|
56
|
+
parts = record.split(_FIELD)
|
|
57
|
+
if len(parts) != 5 or len(parts[0]) not in (40, 64):
|
|
58
|
+
raise GitError(f"unexpected git log record: {record[:80]!r}")
|
|
59
|
+
commits.append(LogCommit(*parts))
|
|
60
|
+
return commits
|
fence/git/runner.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The only place Fence starts a git process.
|
|
2
|
+
|
|
3
|
+
Every argument that comes from outside (a ref, a path) is untrusted: it may come
|
|
4
|
+
from a diff, a CLI argument, or later from model output. So:
|
|
5
|
+
- arguments are always a list, never a shell string;
|
|
6
|
+
- refs are syntax-checked, then resolved by git itself to a commit SHA;
|
|
7
|
+
- paths must stay inside the repo root after resolving symlinks;
|
|
8
|
+
- every call has a timeout.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import codecs
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
from collections.abc import Iterator, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
24
|
+
MAX_STDERR_CHARS = 500
|
|
25
|
+
_VERIFIED: set[tuple[str, str]] = set()
|
|
26
|
+
|
|
27
|
+
# Characters git allows in refs that we also accept: names, SHAs, HEAD~2, main^, origin/main.
|
|
28
|
+
_REF_PATTERN = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._/~^@{}-]*$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitError(Exception):
|
|
32
|
+
"""git failed, or Fence refused to run it."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GitTimeout(GitError):
|
|
36
|
+
"""git did not finish within the timeout."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnsafeInput(GitError):
|
|
40
|
+
"""A ref or path was rejected before reaching git."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git_env() -> dict[str, str]:
|
|
44
|
+
env = dict(os.environ)
|
|
45
|
+
env.update(
|
|
46
|
+
{
|
|
47
|
+
"GIT_TERMINAL_PROMPT": "0", # never block on a credential prompt
|
|
48
|
+
"GIT_PAGER": "cat",
|
|
49
|
+
"GIT_OPTIONAL_LOCKS": "0", # read-only: don't take index locks
|
|
50
|
+
"LC_ALL": "C", # stable, parseable output
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
return env
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Repo:
|
|
58
|
+
root: Path
|
|
59
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def open(cls, path: str | Path, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Repo:
|
|
63
|
+
start = Path(path).resolve()
|
|
64
|
+
if not start.is_dir():
|
|
65
|
+
raise GitError(f"not a directory: {start}")
|
|
66
|
+
top = _run(["rev-parse", "--show-toplevel"], cwd=start, timeout=timeout).strip()
|
|
67
|
+
return cls(root=Path(top).resolve(), timeout=timeout)
|
|
68
|
+
|
|
69
|
+
def run(self, args: Sequence[str], *, timeout: float | None = None) -> str:
|
|
70
|
+
"""Run `git <args>` in the repo root and return stdout."""
|
|
71
|
+
return _run(args, cwd=self.root, timeout=timeout or self.timeout)
|
|
72
|
+
|
|
73
|
+
def verify_ref(self, ref: str) -> str:
|
|
74
|
+
"""Return the full commit SHA for `ref`, or raise UnsafeInput / GitError."""
|
|
75
|
+
if not ref or not _REF_PATTERN.match(ref) or ".." in ref:
|
|
76
|
+
raise UnsafeInput(f"rejected ref: {ref!r}")
|
|
77
|
+
# A full SHA that resolved to a commit once always will: memoize those only.
|
|
78
|
+
key = (str(self.root), ref)
|
|
79
|
+
if key in _VERIFIED:
|
|
80
|
+
return ref
|
|
81
|
+
out = self.run(
|
|
82
|
+
["rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}"]
|
|
83
|
+
)
|
|
84
|
+
sha = out.strip()
|
|
85
|
+
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha):
|
|
86
|
+
raise GitError(f"could not resolve ref: {ref!r}")
|
|
87
|
+
if sha == ref and len(_VERIFIED) < 100_000:
|
|
88
|
+
_VERIFIED.add(key)
|
|
89
|
+
return sha
|
|
90
|
+
|
|
91
|
+
def safe_relpath(self, path: str | Path) -> str:
|
|
92
|
+
"""Return `path` as a repo-relative POSIX path, refusing anything that escapes the root.
|
|
93
|
+
|
|
94
|
+
Works for files that no longer exist in the working tree (deleted in a diff),
|
|
95
|
+
but any symlink that does exist on the way is resolved and checked.
|
|
96
|
+
"""
|
|
97
|
+
raw = str(path)
|
|
98
|
+
if not raw or "\x00" in raw or raw.startswith("-"):
|
|
99
|
+
raise UnsafeInput(f"rejected path: {raw!r}")
|
|
100
|
+
candidate = Path(raw)
|
|
101
|
+
if not candidate.is_absolute():
|
|
102
|
+
candidate = self.root / candidate
|
|
103
|
+
resolved = candidate.resolve(strict=False)
|
|
104
|
+
try:
|
|
105
|
+
rel = resolved.relative_to(self.root)
|
|
106
|
+
except ValueError:
|
|
107
|
+
raise UnsafeInput(f"path escapes repo root: {raw!r}") from None
|
|
108
|
+
rel_posix = PurePosixPath(*rel.parts)
|
|
109
|
+
if str(rel_posix) in ("", "."):
|
|
110
|
+
raise UnsafeInput(f"path is the repo root: {raw!r}")
|
|
111
|
+
return str(rel_posix)
|
|
112
|
+
|
|
113
|
+
def read_blob(self, ref: str, path: str, *, max_bytes: int = 1_000_000) -> str:
|
|
114
|
+
"""Return the contents of `path` at `ref` (`git show ref:path`)."""
|
|
115
|
+
sha = self.verify_ref(ref)
|
|
116
|
+
rel = self.safe_relpath(path)
|
|
117
|
+
# Check the size before reading, so a huge blob in a hostile repo is never loaded.
|
|
118
|
+
size = self.run(["cat-file", "-s", f"{sha}:{rel}"]).strip()
|
|
119
|
+
if not size.isdigit() or int(size) > max_bytes:
|
|
120
|
+
raise GitError(f"file too large to read: {rel} ({size} bytes)")
|
|
121
|
+
return self.run(["show", f"{sha}:{rel}"])
|
|
122
|
+
|
|
123
|
+
def read_blobs(
|
|
124
|
+
self, specs: Sequence[tuple[str, str]], *, max_bytes: int = 1_000_000
|
|
125
|
+
) -> dict[tuple[str, str], str | None]:
|
|
126
|
+
"""Read many `(commit_sha, path)` blobs with two git processes total.
|
|
127
|
+
|
|
128
|
+
Missing, binary-looking or oversized blobs map to None. Sizes are checked with
|
|
129
|
+
--batch-check first, so oversized content is never transferred.
|
|
130
|
+
"""
|
|
131
|
+
ok = [
|
|
132
|
+
(sha, path) for sha, path in dict.fromkeys(specs)
|
|
133
|
+
if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha)
|
|
134
|
+
and path and "\n" not in path and "\r" not in path
|
|
135
|
+
]
|
|
136
|
+
result: dict[tuple[str, str], str | None] = {s: None for s in specs}
|
|
137
|
+
if not ok:
|
|
138
|
+
return result
|
|
139
|
+
query = "".join(f"{sha}:{path}\n" for sha, path in ok).encode("utf-8")
|
|
140
|
+
checks = _run_bytes(["cat-file", "--batch-check"], cwd=self.root, timeout=self.timeout,
|
|
141
|
+
input=query).decode("utf-8", "replace").split("\n")
|
|
142
|
+
small = []
|
|
143
|
+
for spec, line in zip(ok, checks, strict=False):
|
|
144
|
+
parts = line.split(" ")
|
|
145
|
+
if len(parts) == 3 and parts[1] == "blob" and parts[2].isdigit():
|
|
146
|
+
if int(parts[2]) <= max_bytes:
|
|
147
|
+
small.append(spec)
|
|
148
|
+
if not small:
|
|
149
|
+
return result
|
|
150
|
+
query = "".join(f"{sha}:{path}\n" for sha, path in small).encode("utf-8")
|
|
151
|
+
out = _run_bytes(["cat-file", "--batch"], cwd=self.root, timeout=self.timeout,
|
|
152
|
+
input=query)
|
|
153
|
+
pos = 0
|
|
154
|
+
for spec in small:
|
|
155
|
+
nl = out.index(b"\n", pos)
|
|
156
|
+
header = out[pos:nl].decode("utf-8", "replace").split(" ")
|
|
157
|
+
pos = nl + 1
|
|
158
|
+
if len(header) != 3 or not header[2].isdigit():
|
|
159
|
+
continue
|
|
160
|
+
size = int(header[2])
|
|
161
|
+
data = out[pos : pos + size]
|
|
162
|
+
pos += size + 1 # content is followed by a newline
|
|
163
|
+
if b"\x00" not in data[:8000]:
|
|
164
|
+
result[spec] = data.decode("utf-8", "replace")
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def stream(
|
|
168
|
+
self, args: Sequence[str], *, timeout: float | None = None, chunk_size: int = 65536
|
|
169
|
+
) -> Iterator[str]:
|
|
170
|
+
"""Run `git <args>` and yield stdout in text chunks, for output too big to hold.
|
|
171
|
+
|
|
172
|
+
`timeout` bounds the whole run. Stopping iteration early kills git.
|
|
173
|
+
"""
|
|
174
|
+
return _stream(args, cwd=self.root, timeout=timeout or self.timeout, chunk_size=chunk_size)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _stream(args: Sequence[str], *, cwd: Path, timeout: float, chunk_size: int) -> Iterator[str]:
|
|
178
|
+
if isinstance(args, str):
|
|
179
|
+
raise TypeError("git args must be a list, not a string")
|
|
180
|
+
argv = ["git", "-c", "core.fsmonitor=false", "-c", "core.quotePath=false", *args]
|
|
181
|
+
# stderr goes to a file: a PIPE nobody reads can fill up and deadlock git.
|
|
182
|
+
with tempfile.TemporaryFile() as err:
|
|
183
|
+
try:
|
|
184
|
+
proc = subprocess.Popen(
|
|
185
|
+
argv, cwd=cwd, env=_git_env(), stdout=subprocess.PIPE, stderr=err, shell=False
|
|
186
|
+
)
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
raise GitError("git executable not found") from None
|
|
189
|
+
timed_out = threading.Event()
|
|
190
|
+
|
|
191
|
+
def _kill() -> None:
|
|
192
|
+
timed_out.set()
|
|
193
|
+
proc.kill()
|
|
194
|
+
|
|
195
|
+
timer = threading.Timer(timeout, _kill)
|
|
196
|
+
timer.daemon = True
|
|
197
|
+
timer.start()
|
|
198
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
199
|
+
try:
|
|
200
|
+
assert proc.stdout is not None # noqa: S101 - set by stdout=PIPE
|
|
201
|
+
while data := proc.stdout.read1(chunk_size):
|
|
202
|
+
yield decoder.decode(data)
|
|
203
|
+
tail = decoder.decode(b"", final=True)
|
|
204
|
+
if tail:
|
|
205
|
+
yield tail
|
|
206
|
+
returncode = proc.wait()
|
|
207
|
+
finally:
|
|
208
|
+
timer.cancel()
|
|
209
|
+
if proc.poll() is None:
|
|
210
|
+
proc.kill()
|
|
211
|
+
proc.wait()
|
|
212
|
+
if proc.stdout is not None:
|
|
213
|
+
proc.stdout.close()
|
|
214
|
+
if timed_out.is_set():
|
|
215
|
+
raise GitTimeout(f"git {args[0] if args else ''} timed out after {timeout}s")
|
|
216
|
+
if returncode != 0:
|
|
217
|
+
err.seek(0)
|
|
218
|
+
stderr = err.read(MAX_STDERR_CHARS * 4).decode("utf-8", "replace").strip()
|
|
219
|
+
raise GitError(
|
|
220
|
+
f"git {args[0] if args else ''} failed ({returncode}): {stderr[:MAX_STDERR_CHARS]}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _run_bytes(
|
|
225
|
+
args: Sequence[str], *, cwd: Path, timeout: float, input: bytes | None = None
|
|
226
|
+
) -> bytes:
|
|
227
|
+
if isinstance(args, str):
|
|
228
|
+
raise TypeError("git args must be a list, not a string")
|
|
229
|
+
argv = ["git", "-c", "core.fsmonitor=false", "-c", "core.quotePath=false", *args]
|
|
230
|
+
try:
|
|
231
|
+
proc = subprocess.run(
|
|
232
|
+
argv, cwd=cwd, env=_git_env(), input=input, capture_output=True, timeout=timeout,
|
|
233
|
+
check=False, shell=False,
|
|
234
|
+
)
|
|
235
|
+
except subprocess.TimeoutExpired:
|
|
236
|
+
raise GitTimeout(f"git {args[0] if args else ''} timed out after {timeout}s") from None
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
raise GitError("git executable not found") from None
|
|
239
|
+
if proc.returncode != 0:
|
|
240
|
+
stderr = proc.stderr.decode("utf-8", "replace").strip()[:MAX_STDERR_CHARS]
|
|
241
|
+
raise GitError(f"git {args[0] if args else ''} failed ({proc.returncode}): {stderr}")
|
|
242
|
+
return proc.stdout
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _run(args: Sequence[str], *, cwd: Path, timeout: float) -> str:
|
|
246
|
+
# Bytes in, decoded here: text mode would translate CRLF to LF and hand back
|
|
247
|
+
# file content that differs from the blob (and from any diff of it).
|
|
248
|
+
return _run_bytes(args, cwd=cwd, timeout=timeout).decode("utf-8", errors="replace")
|
fence/github/__init__.py
ADDED
|
File without changes
|
fence/github/client.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Minimal GitHub REST client: ETag revalidation, rate-limit respect, pagination.
|
|
2
|
+
|
|
3
|
+
Stdlib only. The token is sent only to the configured https base URL and is
|
|
4
|
+
never logged: every error message passes through `_redact`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from fence.index.store import Store
|
|
17
|
+
|
|
18
|
+
MAX_BODY = 2_000_000
|
|
19
|
+
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GithubError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RateLimited(GithubError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Response:
|
|
32
|
+
status: int
|
|
33
|
+
data: object
|
|
34
|
+
next_url: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Client:
|
|
38
|
+
def __init__(self, base_url: str, token: str | None, store: Store, *, min_remaining: int = 50,
|
|
39
|
+
policy: str = "stop", max_requests: int = 2000, timeout: float = 20.0,
|
|
40
|
+
opener=None) -> None:
|
|
41
|
+
self.base = base_url.rstrip("/")
|
|
42
|
+
self.token = token
|
|
43
|
+
self.store = store
|
|
44
|
+
self.min_remaining = min_remaining
|
|
45
|
+
self.policy = policy
|
|
46
|
+
self.max_requests = max_requests
|
|
47
|
+
self.timeout = timeout
|
|
48
|
+
self.requests = 0
|
|
49
|
+
self.remaining: int | None = None
|
|
50
|
+
self._open = opener or urllib.request.urlopen
|
|
51
|
+
|
|
52
|
+
def _redact(self, text: str) -> str:
|
|
53
|
+
return text.replace(self.token, "***") if self.token else text
|
|
54
|
+
|
|
55
|
+
def get(self, path_or_url: str) -> Response:
|
|
56
|
+
url = path_or_url if path_or_url.startswith(self.base) else self.base + path_or_url
|
|
57
|
+
if not url.startswith(self.base + "/"):
|
|
58
|
+
raise GithubError("refusing to send the token to another host")
|
|
59
|
+
if self.requests >= self.max_requests:
|
|
60
|
+
raise RateLimited(f"request budget of {self.max_requests} per run used up")
|
|
61
|
+
if self.remaining is not None and self.remaining <= self.min_remaining:
|
|
62
|
+
raise RateLimited(f"GitHub rate limit nearly exhausted ({self.remaining} left)")
|
|
63
|
+
cached = self.store.conn.execute(
|
|
64
|
+
"SELECT etag, body FROM http_cache WHERE url = ?", (url,)).fetchone()
|
|
65
|
+
req = urllib.request.Request(url, headers={ # noqa: S310 - base is validated https
|
|
66
|
+
"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
|
|
67
|
+
"User-Agent": "fence",
|
|
68
|
+
**({"Authorization": f"Bearer {self.token}"} if self.token else {}),
|
|
69
|
+
**({"If-None-Match": cached["etag"]} if cached and cached["etag"] else {}),
|
|
70
|
+
})
|
|
71
|
+
self.requests += 1
|
|
72
|
+
try:
|
|
73
|
+
with self._open(req, timeout=self.timeout) as resp: # noqa: S310 - https only
|
|
74
|
+
self._note_limits(resp.headers)
|
|
75
|
+
body = resp.read(MAX_BODY + 1)
|
|
76
|
+
if len(body) > MAX_BODY:
|
|
77
|
+
raise GithubError(f"response too large: {url}")
|
|
78
|
+
etag = resp.headers.get("ETag")
|
|
79
|
+
text = body.decode("utf-8", "replace")
|
|
80
|
+
with self.store.transaction():
|
|
81
|
+
self.store.conn.execute(
|
|
82
|
+
"INSERT OR REPLACE INTO http_cache VALUES(?,?,?,?)",
|
|
83
|
+
(url, etag, text, int(time.time())))
|
|
84
|
+
return Response(resp.status, json.loads(text), _next(resp.headers))
|
|
85
|
+
except urllib.error.HTTPError as e:
|
|
86
|
+
self._note_limits(e.headers)
|
|
87
|
+
if e.code == 304 and cached:
|
|
88
|
+
return Response(200, json.loads(cached["body"]), _next(e.headers))
|
|
89
|
+
if e.code in (403, 429) and (e.headers.get("X-RateLimit-Remaining") == "0"
|
|
90
|
+
or e.code == 429):
|
|
91
|
+
if self.policy == "wait":
|
|
92
|
+
reset = int(e.headers.get("X-RateLimit-Reset") or 0)
|
|
93
|
+
delay = max(1, min(900, reset - int(time.time())))
|
|
94
|
+
time.sleep(delay)
|
|
95
|
+
return self.get(url)
|
|
96
|
+
raise RateLimited("GitHub rate limit reached; re-run later to resume") from None
|
|
97
|
+
return Response(e.code, None, None)
|
|
98
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
99
|
+
raise GithubError(self._redact(f"GitHub unreachable: {e}")) from None
|
|
100
|
+
except ValueError:
|
|
101
|
+
raise GithubError(f"invalid JSON from {url}") from None
|
|
102
|
+
|
|
103
|
+
def paginate(self, path: str, limit: int = 300) -> list:
|
|
104
|
+
out: list = []
|
|
105
|
+
url: str | None = path + ("&" if "?" in path else "?") + "per_page=100"
|
|
106
|
+
while url and len(out) < limit:
|
|
107
|
+
r = self.get(url)
|
|
108
|
+
if r.status != 200 or not isinstance(r.data, list):
|
|
109
|
+
break
|
|
110
|
+
out += r.data
|
|
111
|
+
url = r.next_url
|
|
112
|
+
return out[:limit]
|
|
113
|
+
|
|
114
|
+
def _note_limits(self, headers) -> None:
|
|
115
|
+
rem = headers.get("X-RateLimit-Remaining") if headers else None
|
|
116
|
+
if rem and rem.isdigit():
|
|
117
|
+
self.remaining = int(rem)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _next(headers) -> str | None:
|
|
121
|
+
m = _LINK_NEXT.search(headers.get("Link", "") if headers else "")
|
|
122
|
+
return m.group(1) if m else None
|
fence/github/comment.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""One sticky PR comment, found by a hidden marker and updated in place.
|
|
2
|
+
|
|
3
|
+
Fork PRs get a read-only token: posting then fails with 403, and the caller falls
|
|
4
|
+
back to the job summary. Fence never asks for pull_request_target.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from fence.render import COMMENT_MARKER
|
|
14
|
+
|
|
15
|
+
MAX_COMMENT = 60_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def upsert(api: str, repo: str, pr: int, body: str, token: str, *, opener=None) -> str:
|
|
19
|
+
"""Create or update the sticky comment. Returns 'created', 'updated' or 'forbidden'."""
|
|
20
|
+
if not api.startswith("https://"):
|
|
21
|
+
raise ValueError("api base must be https")
|
|
22
|
+
opener = opener or urllib.request.urlopen
|
|
23
|
+
body = body[:MAX_COMMENT]
|
|
24
|
+
|
|
25
|
+
def call(method: str, url: str, data: dict | None = None):
|
|
26
|
+
req = urllib.request.Request( # noqa: S310 - https only, checked above
|
|
27
|
+
url, method=method, data=json.dumps(data).encode() if data is not None else None,
|
|
28
|
+
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json",
|
|
29
|
+
"User-Agent": "fence", "Content-Type": "application/json"})
|
|
30
|
+
with opener(req, timeout=20) as resp:
|
|
31
|
+
return json.loads(resp.read().decode("utf-8") or "null")
|
|
32
|
+
|
|
33
|
+
base = f"{api.rstrip('/')}/repos/{repo}/issues"
|
|
34
|
+
try:
|
|
35
|
+
page = 1
|
|
36
|
+
while page <= 10:
|
|
37
|
+
comments = call("GET", f"{base}/{pr}/comments?per_page=100&page={page}") or []
|
|
38
|
+
for c in comments:
|
|
39
|
+
if COMMENT_MARKER in (c.get("body") or ""):
|
|
40
|
+
call("PATCH", f"{base}/comments/{c['id']}", {"body": body})
|
|
41
|
+
return "updated"
|
|
42
|
+
if len(comments) < 100:
|
|
43
|
+
break
|
|
44
|
+
page += 1
|
|
45
|
+
call("POST", f"{base}/{pr}/comments", {"body": body})
|
|
46
|
+
return "created"
|
|
47
|
+
except urllib.error.HTTPError as e:
|
|
48
|
+
if e.code in (403, 404):
|
|
49
|
+
return "forbidden"
|
|
50
|
+
raise
|
fence/github/sync.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Fetch PRs, linked issues and review comments for indexed commits.
|
|
2
|
+
|
|
3
|
+
Resumable: every item is committed as soon as it is fetched, and already-stored
|
|
4
|
+
PRs are skipped, so an interrupted or rate-limited run continues next time.
|
|
5
|
+
Text is stored verbatim and stays untrusted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from fence.context import Context
|
|
15
|
+
from fence.github.client import Client, GithubError, RateLimited
|
|
16
|
+
from fence.signals.catalog import extract_text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SyncReport:
|
|
21
|
+
prs: int = 0
|
|
22
|
+
issues: int = 0
|
|
23
|
+
review_comments: int = 0
|
|
24
|
+
unreachable: int = 0
|
|
25
|
+
requests: int = 0
|
|
26
|
+
stopped: str | None = None
|
|
27
|
+
notes: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sync(ctx: Context, *, client: Client | None = None, max_prs: int = 500) -> SyncReport:
|
|
31
|
+
rep = SyncReport()
|
|
32
|
+
cfg = ctx.cfg.github
|
|
33
|
+
if cfg.enabled == "off" or not ctx.slug:
|
|
34
|
+
rep.notes.append("GitHub sync skipped (disabled or no GitHub remote)")
|
|
35
|
+
return rep
|
|
36
|
+
client = client or Client(cfg.api_base_url, ctx.github_token, ctx.store,
|
|
37
|
+
min_remaining=cfg.min_remaining, policy=cfg.rate_limit_policy,
|
|
38
|
+
max_requests=cfg.max_requests_per_run)
|
|
39
|
+
owner, name = ctx.slug
|
|
40
|
+
base = f"/repos/{owner}/{name}"
|
|
41
|
+
min_time = int(time.time()) - cfg.max_age_days * 86400 if cfg.max_age_days else 0
|
|
42
|
+
todo = [r["pr_number"] for r in ctx.store.conn.execute(
|
|
43
|
+
"SELECT DISTINCT c.pr_number FROM commits c LEFT JOIN prs p ON p.number = c.pr_number "
|
|
44
|
+
"WHERE c.pr_number IS NOT NULL AND p.number IS NULL AND c.orphaned = 0 "
|
|
45
|
+
"AND c.committer_time >= ? ORDER BY c.committer_time DESC LIMIT ?",
|
|
46
|
+
(min_time, max_prs))]
|
|
47
|
+
try:
|
|
48
|
+
for n in todo if cfg.sync_prs else []:
|
|
49
|
+
_sync_pr(ctx, client, base, n, rep)
|
|
50
|
+
except RateLimited as e:
|
|
51
|
+
rep.stopped = str(e)
|
|
52
|
+
except GithubError as e:
|
|
53
|
+
rep.stopped = str(e)
|
|
54
|
+
rep.requests = client.requests
|
|
55
|
+
return rep
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _sync_pr(ctx: Context, client: Client, base: str, n: int, rep: SyncReport) -> None:
|
|
59
|
+
r = client.get(f"{base}/pulls/{n}")
|
|
60
|
+
if r.status != 200 or not isinstance(r.data, dict):
|
|
61
|
+
with ctx.store.transaction():
|
|
62
|
+
ctx.store.conn.execute(
|
|
63
|
+
"INSERT OR REPLACE INTO prs(number, state, fetched_at) VALUES(?, 'unreachable', ?)",
|
|
64
|
+
(n, int(time.time())))
|
|
65
|
+
rep.unreachable += 1
|
|
66
|
+
return
|
|
67
|
+
d = r.data
|
|
68
|
+
labels = [lb.get("name", "") for lb in d.get("labels") or [] if isinstance(lb, dict)]
|
|
69
|
+
reviewers: set[str] = set()
|
|
70
|
+
comments = []
|
|
71
|
+
if ctx.cfg.github.sync_review_comments:
|
|
72
|
+
for c in client.paginate(f"{base}/pulls/{n}/comments", 200):
|
|
73
|
+
comments.append(c)
|
|
74
|
+
for rv in client.paginate(f"{base}/pulls/{n}/reviews", 100):
|
|
75
|
+
if isinstance(rv, dict) and rv.get("user"):
|
|
76
|
+
reviewers.add(rv["user"].get("login", ""))
|
|
77
|
+
if rv.get("body"):
|
|
78
|
+
comments.append({**rv, "path": None, "line": None})
|
|
79
|
+
with ctx.store.transaction():
|
|
80
|
+
ctx.store.conn.execute(
|
|
81
|
+
"INSERT OR REPLACE INTO prs VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
82
|
+
(n, d.get("title"), d.get("body") or "", json.dumps(labels), d.get("state"),
|
|
83
|
+
d.get("merged_at"), d.get("merge_commit_sha"), d.get("html_url"),
|
|
84
|
+
(d.get("user") or {}).get("login"), json.dumps(sorted(reviewers)),
|
|
85
|
+
int(time.time())))
|
|
86
|
+
for c in comments:
|
|
87
|
+
if not isinstance(c, dict) or "id" not in c:
|
|
88
|
+
continue
|
|
89
|
+
ctx.store.conn.execute(
|
|
90
|
+
"INSERT OR REPLACE INTO review_comments VALUES(?,?,?,?,?,?,?,?)",
|
|
91
|
+
(c["id"], n, c.get("path"), c.get("line"), c.get("body") or "",
|
|
92
|
+
(c.get("user") or {}).get("login"), c.get("html_url"),
|
|
93
|
+
c.get("created_at") or c.get("submitted_at")))
|
|
94
|
+
rep.prs += 1
|
|
95
|
+
rep.review_comments += len(comments)
|
|
96
|
+
if ctx.cfg.github.sync_issues:
|
|
97
|
+
refs = {d for s, d in extract_text(d.get("body") or "")
|
|
98
|
+
if s in ("text.closes_issue", "text.issue_ref") and d.startswith("#")}
|
|
99
|
+
for ref in sorted(refs)[:10]:
|
|
100
|
+
_sync_issue(ctx, client, base, int(ref[1:]), n, rep)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _sync_issue(ctx: Context, client: Client, base: str, n: int, pr: int,
|
|
104
|
+
rep: SyncReport) -> None:
|
|
105
|
+
if ctx.store.conn.execute("SELECT 1 FROM issues WHERE number=?", (n,)).fetchone():
|
|
106
|
+
return
|
|
107
|
+
r = client.get(f"{base}/issues/{n}")
|
|
108
|
+
with ctx.store.transaction():
|
|
109
|
+
if r.status != 200 or not isinstance(r.data, dict):
|
|
110
|
+
ctx.store.conn.execute(
|
|
111
|
+
"INSERT OR REPLACE INTO issues(number, fetched_at, retrieval_status) "
|
|
112
|
+
"VALUES(?, ?, 'unreachable')", (n, int(time.time())))
|
|
113
|
+
rep.unreachable += 1
|
|
114
|
+
return
|
|
115
|
+
d = r.data
|
|
116
|
+
labels = [lb.get("name", "") for lb in d.get("labels") or [] if isinstance(lb, dict)]
|
|
117
|
+
ctx.store.conn.execute(
|
|
118
|
+
"INSERT OR REPLACE INTO issues VALUES(?,?,?,?,?,?,?,?,'ok')",
|
|
119
|
+
(n, d.get("title"), d.get("body") or "", json.dumps(labels), d.get("state"),
|
|
120
|
+
d.get("closed_at"), d.get("html_url"), int(time.time())))
|
|
121
|
+
ctx.store.conn.execute("INSERT OR IGNORE INTO pr_issue_links VALUES(?,?,?)",
|
|
122
|
+
(pr, n, "references"))
|
|
123
|
+
rep.issues += 1
|
fence/globs.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Gitignore-style globs for include/exclude/ignore_paths: `*`, `?`, `**`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=512)
|
|
11
|
+
def _compile(pattern: str) -> re.Pattern[str]:
|
|
12
|
+
p = pattern.strip().lstrip("/")
|
|
13
|
+
if p.endswith("/"):
|
|
14
|
+
p += "**"
|
|
15
|
+
out = []
|
|
16
|
+
i = 0
|
|
17
|
+
while i < len(p):
|
|
18
|
+
if p.startswith("**/", i):
|
|
19
|
+
out.append("(?:.*/)?")
|
|
20
|
+
i += 3
|
|
21
|
+
elif p.startswith("**", i):
|
|
22
|
+
out.append(".*")
|
|
23
|
+
i += 2
|
|
24
|
+
elif p[i] == "*":
|
|
25
|
+
out.append("[^/]*")
|
|
26
|
+
i += 1
|
|
27
|
+
elif p[i] == "?":
|
|
28
|
+
out.append("[^/]")
|
|
29
|
+
i += 1
|
|
30
|
+
else:
|
|
31
|
+
out.append(re.escape(p[i]))
|
|
32
|
+
i += 1
|
|
33
|
+
# A pattern without a slash matches at any depth, like .gitignore.
|
|
34
|
+
prefix = "" if "/" in pattern.strip().rstrip("/") else "(?:.*/)?"
|
|
35
|
+
return re.compile(prefix + "".join(out) + r"\Z")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def match_any(path: str, patterns: Iterable[str]) -> bool:
|
|
39
|
+
return any(_compile(p).match(path) for p in patterns)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def included(path: str, include: Iterable[str], exclude: Iterable[str]) -> bool:
|
|
43
|
+
return match_any(path, include) and not match_any(path, exclude)
|
fence/index/__init__.py
ADDED
|
File without changes
|